yearning配置webhook通知
yearning内置的webhook通知目前只支持钉钉,当然也可以通过程序自定义通知
配置飞书webhook通知
目录内容
$ tree send-feishu-webhook/
send-feishu-webhook/
├── config.yaml
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
└── send-feishu-webhook.go
1 directory, 6 files
配置 文件 config.yaml
feishu:
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
secret: "xxxxxxxxxxxxxxxxxxxxxxx"
server:
port: 5000
mode: "release"
程序代码
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// Config 配置文件结构体
type Config struct {
Feishu FeishuConfig `yaml:"feishu"`
Server ServerConfig `yaml:"server"`
}
type FeishuConfig struct {
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"`
}
type ServerConfig struct {
Port int `yaml:"port"`
Mode string `yaml:"mode"`
}
// 全局配置变量
var AppConfig *Config
// LoadConfig 加载配置文件
func LoadConfig(configPath string) (*Config, error) {
config := &Config{}
file, err := os.Open(configPath)
if err != nil {
return nil, err
}
defer file.Close()
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(config); err != nil {
return nil, err
}
return config, nil
}
// InitConfig 初始化配置
func InitConfig() {
configPath := "config.yaml"
if path := os.Getenv("CONFIG_PATH"); path != "" {
configPath = path
}
config, err := LoadConfig(configPath)
if err != nil {
log.Fatalf("Error loading config: %v", err)
}
AppConfig = config
log.Printf("Configuration loaded successfully from %s", configPath)
}
// calculateSignature 计算签名
func calculateSignature(secret string, timestamp int64) string {
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
hmacCode := hmac.New(sha256.New, []byte(stringToSign))
hmacCode.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(hmacCode.Sum(nil))
return signature
}
// extractMarkdownText 提取并分行处理 markdown 的 text 字段
func extractMarkdownText(data map[string]interface{}) []string {
markdown, ok := data["markdown"].(map[string]interface{})
if !ok {
log.Println("Markdown field not found or invalid")
return []string{}
}
text, ok := markdown["text"].(string)
if !ok || text == "" {
log.Println("Markdown text is empty or not provided.")
return []string{}
}
// 按换行符分割,并过滤掉空行
lines := strings.Split(text, "\n")
var result []string
for _, line := range lines {
if trimmed := strings.TrimSpace(line); trimmed != "" {
// 只过滤真正的标题行,保留所有包含冒号的键值对
if !isRedundantTitleLine(trimmed) {
result = append(result, trimmed)
}
}
}
log.Println("Extracted lines from markdown text:")
for _, line := range result {
log.Println(line)
}
return result
}
// isRedundantTitleLine 判断是否为冗余的标题行
func isRedundantTitleLine(line string) bool {
redundantTitles := []string{
"Yearning 工单通知",
"## Yearning工单通知",
"# Yearning工单通知",
"Yearning工单通知",
}
for _, title := range redundantTitles {
if strings.TrimSpace(line) == strings.TrimSpace(title) {
return true
}
}
return false
}
// getStatusEmoji 根据状态返回对应的emoji
func getStatusEmoji(status string) string {
switch {
case strings.Contains(status, "已提交"):
return "📤"
case strings.Contains(status, "已审核"):
return "✅"
case strings.Contains(status, "已执行"):
return "🚀"
case strings.Contains(status, "已驳回"):
return "❌"
case strings.Contains(status, "已转交"):
return "↪️"
case strings.Contains(status, "待处理"):
return "⏳"
default:
return "📋"
}
}
// cleanInputLine 清理输入行,去除多余的*号和空格
func cleanInputLine(line string) (string, string) {
// 去除行首的•和空格
cleanedLine := strings.TrimSpace(strings.TrimPrefix(line, "•"))
// 使用正则表达式匹配键值对
re := regexp.MustCompile(`([^:]+):\s*(.+)`)
matches := re.FindStringSubmatch(cleanedLine)
if len(matches) == 3 {
key := strings.TrimSpace(matches[1])
value := strings.TrimSpace(matches[2])
// 去除key中的*号
key = strings.Trim(key, "*")
key = strings.TrimSpace(key)
// 去除value中的*号
value = strings.Trim(value, "*")
value = strings.TrimSpace(value)
return key, value
}
return "", cleanedLine
}
// getBeijingTime 获取北京时间(UTC+8)
func getBeijingTime() string {
// 设置时区为北京时间
beijingLocation, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
// 如果加载时区失败,使用 UTC+8
beijingLocation = time.FixedZone("CST", 8*60*60)
}
now := time.Now().In(beijingLocation)
return now.Format("2006-01-02 15:04:05")
}
// formatNotificationContent 格式化通知内容
func formatNotificationContent(lines []string) string {
var formattedLines []string
// 添加北京时间信息(加粗)
beijingTime := getBeijingTime()
formattedLines = append(formattedLines, fmt.Sprintf("🕐 **通知时间**: %s", beijingTime))
for _, line := range lines {
key, value := cleanInputLine(line)
if key != "" && value != "" {
// 为不同字段添加emoji和格式,字段名加粗
switch key {
case "工单编号":
// 移除开头和结尾的 ` 符号
cleanValue := strings.Trim(value, "`")
formattedLines = append(formattedLines, fmt.Sprintf("📄 **%s**: %s", key, cleanValue))
case "数据源":
formattedLines = append(formattedLines, fmt.Sprintf("🗄️ **%s**: %s", key, value))
case "工单说明":
formattedLines = append(formattedLines, fmt.Sprintf("📝 **%s**: %s", key, value))
case "提交人员":
formattedLines = append(formattedLines, fmt.Sprintf("👤 **%s**: %s", key, value))
case "下一步操作人":
formattedLines = append(formattedLines, fmt.Sprintf("➡️ **%s**: %s", key, value))
case "平台地址":
// 直接显示域名,不显示为链接
formattedLines = append(formattedLines, fmt.Sprintf("🌐 **%s**: %s", key, value))
case "状态":
// 根据状态显示不同的emoji,状态值也加粗
statusEmoji := getStatusEmoji(value)
formattedLines = append(formattedLines, fmt.Sprintf("%s **%s**: **%s**", statusEmoji, key, value))
default:
formattedLines = append(formattedLines, fmt.Sprintf("• **%s**: %s", key, value))
}
} else {
// 如果无法解析为键值对,直接显示原始内容
formattedLines = append(formattedLines, fmt.Sprintf("• %s", line))
}
}
return strings.Join(formattedLines, "\n")
}
// sendFeishuNotification 发送通知到飞书(优化版)
func sendFeishuNotification(lines []string) {
if AppConfig == nil {
log.Println("Configuration not initialized")
return
}
timestamp := time.Now().Unix()
signature := calculateSignature(AppConfig.Feishu.Secret, timestamp)
// 重新格式化消息内容
formattedContent := formatNotificationContent(lines)
sendData := map[string]interface{}{
"timestamp": timestamp,
"sign": signature,
"msg_type": "interactive",
"card": map[string]interface{}{
"config": map[string]interface{}{
"wide_screen_mode": true,
},
"elements": []map[string]interface{}{
{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": formattedContent,
},
},
},
"header": map[string]interface{}{
"template": "blue",
"title": map[string]interface{}{
"content": "📋 Yearning 工单通知",
"tag": "plain_text",
},
},
},
}
jsonData, err := json.Marshal(sendData)
if err != nil {
log.Printf("Error marshaling JSON: %v", err)
return
}
log.Printf("Webhook URL: %s", AppConfig.Feishu.WebhookURL)
log.Printf("Request Body: %s", string(jsonData))
resp, err := http.Post(AppConfig.Feishu.WebhookURL, "application/json", strings.NewReader(string(jsonData)))
if err != nil {
log.Printf("Error sending request: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Error reading response: %v", err)
return
}
log.Printf("Response: %d %s", resp.StatusCode, string(body))
if resp.StatusCode == 200 {
log.Println("Notification sent successfully.")
} else {
log.Printf("Failed to send notification. Status code: %d, Response: %s", resp.StatusCode, string(body))
}
}
// handleYearningNotification 处理接收到的 Yearning 通知
func handleYearningNotification(c *gin.Context) {
var data map[string]interface{}
if err := c.BindJSON(&data); err != nil {
log.Printf("Invalid request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
jsonData, _ := json.MarshalIndent(data, "", " ")
log.Printf("Received Body: %s", string(jsonData))
// 提取 markdown 的 text 字段并逐行处理
lines := extractMarkdownText(data)
// 发送提取内容到飞书
sendFeishuNotification(lines)
c.JSON(http.StatusOK, gin.H{"message": "Notification processed."})
}
func main() {
// 初始化配置
InitConfig()
// 设置Gin模式
gin.SetMode(AppConfig.Server.Mode)
router := gin.Default()
// 同时支持根路径和/webhook/yearning路径
router.POST("/", handleYearningNotification)
router.POST("/webhook/yearning", handleYearningNotification)
port := fmt.Sprintf(":%d", AppConfig.Server.Port)
log.Printf("Server starting on %s", port)
if err := router.Run(port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
Dockerfile内容
# =========================
# 构建阶段
# =========================
FROM golang:1.25-alpine3.22 AS builder
# 设置 Go 模块代理加速
ENV GOPROXY=https://goproxy.cn,direct \
GOSUMDB=off
# 设置工作目录
WORKDIR /app
# 复制 go.mod 和 go.sum 文件(利用缓存)
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码
COPY . .
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux go build -o send-feishu-webhook .
# =========================
# 运行阶段
# =========================
FROM alpine:3.18
# 安装 CA 证书(用于 HTTPS 请求)
RUN apk --no-cache add ca-certificates
# 创建非 root 用户
RUN adduser -D -g '' appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件和配置文件
COPY /app/send-feishu-webhook .
COPY /app/config.yaml .
# 设置权限
RUN chown -R appuser:appuser /app
USER appuser
# 暴露端口
EXPOSE 5000
# 启动应用
CMD ["./send-feishu-webhook"]
docker compose内容
services:
send-feishu-webhook:
image: xxx/send-feishu-webhook:v1
ports:
- "5000:5000"
environment:
- TZ=Asia/Shanghai
volumes:
- ./config.yaml:/app/config.yaml:ro
restart: always
hostname: send-feishu-webhook
container_name: send-feishu-webhook
networks:
- send-feishu-webhook
networks:
send-feishu-webhook:
driver: bridge
通知效果
配置企业微信webhook通知
目录内容
$ tree send-qiyewechat-webhook/
send-qiyewechat-webhook/
├── config.yaml
├── docker-compose.yaml
├── Dockerfile
├── go.mod
├── go.sum
└── send-qiyewechat-webhook.go
1 directory, 6 files
配置文件 config.yaml
feishu:
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
secret: "xxxxxxxxxxxxxxxxxxxxxxx"
server:
port: 5000
mode: "release"
程序代码
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// Config 配置文件结构体
type Config struct {
WeCom WeComConfig `yaml:"wecom"`
Server ServerConfig `yaml:"server"`
}
type WeComConfig struct {
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"`
}
type ServerConfig struct {
Port int `yaml:"port"`
Mode string `yaml:"mode"`
}
// 全局配置变量
var AppConfig *Config
// LoadConfig 加载配置文件
func LoadConfig(configPath string) (*Config, error) {
config := &Config{}
file, err := os.Open(configPath)
if err != nil {
return nil, err
}
defer file.Close()
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(config); err != nil {
return nil, err
}
return config, nil
}
// InitConfig 初始化配置
func InitConfig() {
configPath := "config.yaml"
if path := os.Getenv("CONFIG_PATH"); path != "" {
configPath = path
}
config, err := LoadConfig(configPath)
if err != nil {
log.Fatalf("Error loading config: %v", err)
}
AppConfig = config
log.Printf("Configuration loaded successfully from %s", configPath)
}
// calculateWxSignature 计算企业微信签名
func calculateWxSignature(secret string, timestamp int64) string {
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// extractMarkdownText 提取并分行处理 markdown 的 text 字段
func extractMarkdownText(data map[string]interface{}) []string {
markdown, ok := data["markdown"].(map[string]interface{})
if !ok {
log.Println("Markdown field not found or invalid")
return []string{}
}
text, ok := markdown["text"].(string)
if !ok || text == "" {
log.Println("Markdown text is empty or not provided.")
return []string{}
}
lines := strings.Split(text, "\n")
var result []string
for _, line := range lines {
if trimmed := strings.TrimSpace(line); trimmed != "" {
if !isRedundantTitleLine(trimmed) {
result = append(result, trimmed)
}
}
}
log.Println("Extracted lines from markdown text:")
for _, line := range result {
log.Println(line)
}
return result
}
// isRedundantTitleLine 判断是否为冗余的标题行
func isRedundantTitleLine(line string) bool {
redundantTitles := []string{
"Yearning 工单通知",
"## Yearning工单通知",
"# Yearning工单通知",
}
for _, title := range redundantTitles {
if strings.TrimSpace(line) == strings.TrimSpace(title) {
return true
}
}
return false
}
// getStatusEmoji 根据状态返回对应的emoji
func getStatusEmoji(status string) string {
switch {
case strings.Contains(status, "已提交"):
return "📤"
case strings.Contains(status, "已审核"):
return "✅"
case strings.Contains(status, "