mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
ce28f63659
Result: {"status":"keep","total_issues":38,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47}
276 lines
6.8 KiB
Go
276 lines
6.8 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package push
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/pkg/httppool"
|
|
)
|
|
|
|
func init() {
|
|
Register("lark", &LarkPusher{})
|
|
}
|
|
|
|
const (
|
|
msgTypeInteractive = "interactive"
|
|
)
|
|
|
|
// LarkPusher 飞书 Webhook 机器人推送实现
|
|
type LarkPusher struct{}
|
|
|
|
type larkTextContent struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type larkCardHeaderTitle struct {
|
|
Content string `json:"content"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
type larkCardHeader struct {
|
|
Template string `json:"template"` // "blue", "orange", "red" etc.
|
|
Title larkCardHeaderTitle `json:"title"`
|
|
}
|
|
|
|
type larkCardElementText struct {
|
|
Content string `json:"content"`
|
|
Tag string `json:"tag"` // "lark_md"
|
|
}
|
|
|
|
type larkCardElement struct {
|
|
Tag string `json:"tag"` // "div"
|
|
Text larkCardElementText `json:"text"`
|
|
}
|
|
|
|
type larkCardContent struct {
|
|
Header larkCardHeader `json:"header"`
|
|
Elements []larkCardElement `json:"elements"`
|
|
}
|
|
|
|
type larkMessageRequest struct {
|
|
MessageType string `json:"msg_type"`
|
|
Timestamp string `json:"timestamp,omitempty"`
|
|
Sign string `json:"sign,omitempty"`
|
|
Content larkTextContent `json:"content,omitempty"`
|
|
Card *larkCardContent `json:"card,omitempty"`
|
|
}
|
|
|
|
type larkMessageResponse struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
|
|
// Send 执行飞书消息发送
|
|
//
|
|
//nolint:nestif,cyclop
|
|
func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) {
|
|
if cfg.URL == "" {
|
|
return "", errors.New("lark: URL is required")
|
|
}
|
|
|
|
var req larkMessageRequest
|
|
|
|
// 1. 如果有自定义模板,我们尝试进行解析
|
|
if template != "" {
|
|
rendered := ParseTemplate(template, body)
|
|
|
|
// 尝试解析原生的 Lark Card
|
|
var customCard larkCardContent
|
|
var rawMap map[string]any
|
|
_ = json.Unmarshal([]byte(rendered), &rawMap)
|
|
|
|
if rawMap != nil && rawMap["elements"] != nil {
|
|
// 如果包含 elements 字段,说明是用户定制的原生飞书卡片 JSON
|
|
if err := json.Unmarshal([]byte(rendered), &customCard); err == nil {
|
|
req.MessageType = msgTypeInteractive
|
|
req.Card = &customCard
|
|
} else {
|
|
req.MessageType = "text"
|
|
req.Content.Text = rendered
|
|
}
|
|
} else {
|
|
// 说明配置的是系统统一通知消息 of JSON 模板:{"title": "...", "content": "...", "level": "..."}
|
|
type larkNotificationMessage struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Level string `json:"level"`
|
|
}
|
|
var msg larkNotificationMessage
|
|
if err := json.Unmarshal([]byte(rendered), &msg); err == nil && (msg.Title != "" || msg.Content != "") {
|
|
title := msg.Title
|
|
if title == "" {
|
|
title = defaultTitle
|
|
}
|
|
content := msg.Content
|
|
level := strings.ToUpper(msg.Level)
|
|
if level == "" {
|
|
level = levelInfo
|
|
}
|
|
|
|
headerColor := "blue"
|
|
switch level {
|
|
case "IMPORTANT":
|
|
headerColor = "orange"
|
|
case "CRITICAL":
|
|
headerColor = "red"
|
|
}
|
|
|
|
req.MessageType = msgTypeInteractive
|
|
req.Card = &larkCardContent{
|
|
Header: larkCardHeader{
|
|
Template: headerColor,
|
|
Title: larkCardHeaderTitle{
|
|
Content: title,
|
|
Tag: "plain_text",
|
|
},
|
|
},
|
|
Elements: []larkCardElement{
|
|
{
|
|
Tag: "div",
|
|
Text: larkCardElementText{
|
|
Content: content,
|
|
Tag: "lark_md",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
} else {
|
|
// 兜底:如果无法按 JSON 解析出结构化字段,当做普通文本发送
|
|
req.MessageType = "text"
|
|
req.Content.Text = rendered
|
|
}
|
|
}
|
|
} else {
|
|
// 2. 如果无模板,默认生成一个精美的飞书互动卡片
|
|
title := defaultTitle
|
|
if t, ok := body["title"].(string); ok && t != "" {
|
|
title = t
|
|
}
|
|
|
|
var content string
|
|
if c, ok := body["content"].(string); ok && c != "" {
|
|
content = c
|
|
} else {
|
|
// 兜底:如果连 content 都没有,把 body 里的所有值拼成 markdown
|
|
var parts []string
|
|
for k, v := range body {
|
|
parts = append(parts, fmt.Sprintf("**%s**: %v", k, v))
|
|
}
|
|
content = strings.Join(parts, "\n")
|
|
}
|
|
|
|
level := levelInfo
|
|
if l, ok := body["level"].(string); ok && l != "" {
|
|
level = strings.ToUpper(l)
|
|
}
|
|
|
|
// 根据级别确定飞书卡片头部的背景色模板
|
|
headerColor := "blue"
|
|
switch level {
|
|
case "IMPORTANT":
|
|
headerColor = "orange"
|
|
case "CRITICAL":
|
|
headerColor = "red"
|
|
}
|
|
|
|
req.MessageType = msgTypeInteractive
|
|
req.Card = &larkCardContent{
|
|
Header: larkCardHeader{
|
|
Template: headerColor,
|
|
Title: larkCardHeaderTitle{
|
|
Content: title,
|
|
Tag: "plain_text",
|
|
},
|
|
},
|
|
Elements: []larkCardElement{
|
|
{
|
|
Tag: "div",
|
|
Text: larkCardElementText{
|
|
Content: content,
|
|
Tag: "lark_md",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 3. 计算签名 (如果配置了 secret)
|
|
if cfg.Secret != "" {
|
|
timestamp := time.Now().Unix()
|
|
sign, err := larkSign(cfg.Secret, timestamp)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lark: sign failed: %w", err)
|
|
}
|
|
req.Timestamp = strconv.FormatInt(timestamp, 10)
|
|
req.Sign = sign
|
|
}
|
|
|
|
jsonData, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lark: marshal request failed: %w", err)
|
|
}
|
|
|
|
// 4. 发送 POST 请求
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return "", fmt.Errorf("lark: create http request failed: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
client := httppool.NewClient(defaultHTTPClientTimeout)
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lark: http request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("lark: http status %s", resp.Status)
|
|
}
|
|
|
|
var res larkMessageResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
|
return "", fmt.Errorf("lark: decode response failed: %w", err)
|
|
}
|
|
|
|
if res.Code != 0 {
|
|
return "", fmt.Errorf("lark: send message failed, code %d: %s", res.Code, res.Msg)
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// ValidateConfig 校验飞书配置
|
|
func (p *LarkPusher) ValidateConfig(cfg Config) error {
|
|
if cfg.URL == "" {
|
|
return errors.New("webhook URL is required")
|
|
}
|
|
if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
|
|
return errors.New("webhook URL must start with http:// or https://")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func larkSign(secret string, timestamp int64) (string, error) {
|
|
stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
|
h := hmac.New(sha256.New, []byte(stringToSign))
|
|
_, err := h.Write(nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
|
}
|