mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
fa689aedbc
自定义 Webhook 在 HTTP 200 但业务 errcode 非零时记为失败,任务日志记录上游响应。 OTel Resource 改为 NewSchemaless,避免 semconv 与 SDK 版本冲突。 前端用 next/font 自托管 Inter,并忽略浏览器扩展改写 body 引起的 hydration 警告。
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package push
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/pkg/httppool"
|
|
)
|
|
|
|
func init() {
|
|
Register("custom", &CustomPusher{})
|
|
}
|
|
|
|
// maxCustomResponseBytes 限制读取 Webhook 响应体的最大字节数,防止无界读取。
|
|
const maxCustomResponseBytes = 4096
|
|
|
|
// CustomPusher 自定义 Webhook 发送实现
|
|
type CustomPusher struct{}
|
|
|
|
// Send 发送自定义 webhook
|
|
func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) {
|
|
if cfg.URL == "" {
|
|
return "", errors.New("custom: URL is required")
|
|
}
|
|
|
|
var reqBody []byte
|
|
|
|
if template != "" {
|
|
// 替换模板中的 {{key}} 占位符
|
|
rendered := ParseTemplate(template, body)
|
|
reqBody = []byte(rendered)
|
|
} else {
|
|
// 兜底:直接把 body 转为 JSON 字符串发送
|
|
var err error
|
|
reqBody, err = json.Marshal(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("custom: marshal body failed: %w", err)
|
|
}
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return "", fmt.Errorf("custom: create http request failed: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
// 如果配置了 Key 且格式为 "HeaderName:HeaderValue",我们可以附加测试用 Header
|
|
if cfg.Key != "" && strings.Contains(cfg.Key, ":") {
|
|
parts := strings.SplitN(cfg.Key, ":", 2) //nolint:mnd
|
|
httpReq.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
|
|
}
|
|
|
|
client := httppool.NewClient(defaultHTTPClientTimeout)
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("custom: http request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCustomResponseBytes))
|
|
upstreamResp := strings.TrimSpace(string(bodyBytes))
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 { //nolint:mnd
|
|
return upstreamResp, fmt.Errorf("custom: http status %s", resp.Status)
|
|
}
|
|
|
|
// 部分 Webhook(如企业微信、钉钉)即使业务失败也返回 HTTP 200,
|
|
// 仅当响应体包含非零 errcode 时才判定为发送失败,避免审计记录误报成功。
|
|
var apiResp struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.Unmarshal(bodyBytes, &apiResp); err == nil && apiResp.ErrCode != 0 {
|
|
return upstreamResp, fmt.Errorf("custom: webhook rejected: errcode=%d errmsg=%q", apiResp.ErrCode, apiResp.ErrMsg)
|
|
}
|
|
|
|
return upstreamResp, nil
|
|
}
|
|
|
|
// ValidateConfig 校验自定义配置
|
|
func (p *CustomPusher) 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
|
|
}
|