- internal/lark:飞书自定义机器人交互卡片(wide_screen_mode + 彩色头部 green/red/yellow + lark_md 字段区 + 查看构建详情按钮),支持签名加签 (timestamp+sign HMAC-SHA256,密钥 enc:v1: 加密存储) - 双引擎收口:原生模拟引擎与 Jenkins 真实引擎运行结束均推送卡片; 应用/分支/构建(#N 或 runId)/状态/时间;Jenkins 构建附 externalURL 按钮直达构建详情页 - Webhook 优先级:流水线「通知 Webhook」> 全局设置(需启用) - 系统设置新增飞书通知配置区(启用开关/Webhook/签名密钥只写/ 发送测试卡片);POST /api/settings/test-lark - E2E 已验证:本地模拟机器人收到完整卡片 JSON,测试卡片正常
126 lines
3.3 KiB
Go
126 lines
3.3 KiB
Go
// Package lark 飞书/Lark 群自定义机器人卡片通知。
|
||
package lark
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// ReleaseCardParams 发布结果卡片字段
|
||
type ReleaseCardParams struct {
|
||
AppName string // 应用名
|
||
Branch string // 分支
|
||
Build string // 构建号(如 #172 或 pr-xxx)
|
||
Status string // success | failed | 其他
|
||
Time string // 展示时间
|
||
BuildURL string // 「查看构建详情」按钮地址(空则不放按钮)
|
||
}
|
||
|
||
func statusMeta(status string) (template, text string) {
|
||
switch status {
|
||
case "success":
|
||
return "green", "✅ 成功"
|
||
case "failed":
|
||
return "red", "❌ 失败"
|
||
case "cancelled":
|
||
return "yellow", "⚠️ 已取消"
|
||
default:
|
||
return "yellow", "⚠️ " + status
|
||
}
|
||
}
|
||
|
||
// BuildReleaseCard 组装发布结果卡片(结构对齐现有 Jenkins 通知脚本)
|
||
func BuildReleaseCard(p ReleaseCardParams) map[string]any {
|
||
_, statusText := statusMeta(p.Status)
|
||
template, _ := statusMeta(p.Status)
|
||
|
||
elements := []any{
|
||
map[string]any{
|
||
"tag": "div",
|
||
"text": map[string]any{
|
||
"tag": "lark_md",
|
||
"content": fmt.Sprintf(
|
||
"**应用:** %s\n**分支:** %s\n**构建:** %s\n**状态:** %s\n**时间:** %s",
|
||
p.AppName, p.Branch, p.Build, statusText, p.Time,
|
||
),
|
||
},
|
||
},
|
||
}
|
||
if p.BuildURL != "" {
|
||
elements = append(elements, map[string]any{
|
||
"tag": "action",
|
||
"actions": []any{map[string]any{
|
||
"tag": "button",
|
||
"text": map[string]any{"tag": "plain_text", "content": "查看构建详情"},
|
||
"url": p.BuildURL,
|
||
"type": "default",
|
||
}},
|
||
})
|
||
}
|
||
|
||
_, statusOnly := statusMeta(p.Status)
|
||
title := "发布结果通知 - " + statusOnly
|
||
return map[string]any{
|
||
"msg_type": "interactive",
|
||
"card": map[string]any{
|
||
"config": map[string]any{"wide_screen_mode": true},
|
||
"header": map[string]any{
|
||
"template": template,
|
||
"title": map[string]any{"tag": "plain_text", "content": title},
|
||
},
|
||
"elements": elements,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Send 发送卡片;secret 非空时按飞书自定义机器人规范加签(timestamp+sign)
|
||
func Send(webhook, secret string, card map[string]any) error {
|
||
if webhook == "" {
|
||
return fmt.Errorf("webhook 未配置")
|
||
}
|
||
if secret != "" {
|
||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||
mac := hmac.New(sha256.New, []byte(ts+"\n"+secret))
|
||
mac.Write(nil)
|
||
card["timestamp"] = ts
|
||
card["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
body, err := json.Marshal(card)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(webhook, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||
}
|
||
// 飞书成功返回 {"code":0,...} / {"StatusCode":0}
|
||
var out struct {
|
||
Code int `json:"code"`
|
||
StatusCode int `json:"StatusCode"`
|
||
Msg string `json:"msg"`
|
||
ErrorMessage string `json:"ErrorMessage"`
|
||
}
|
||
_ = json.Unmarshal(data, &out)
|
||
if out.Code != 0 || out.StatusCode != 0 {
|
||
msg := out.Msg
|
||
if msg == "" {
|
||
msg = out.ErrorMessage
|
||
}
|
||
return fmt.Errorf("飞书返回错误: %s", msg)
|
||
}
|
||
return nil
|
||
}
|