mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 07:06:36 +08:00
feat(message_gateway): integrate nikoksr/notify engine and support multi-channel push
This commit is contained in:
@@ -4,38 +4,22 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBarkServer = "https://api.day.app"
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/bark"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("bark", &BarkPusher{})
|
||||
}
|
||||
|
||||
// BarkPusher Bark iOS 客户端通知推送实现
|
||||
// BarkPusher 基于 nikoksr/notify 的 Bark iOS 客户端通知推送实现
|
||||
type BarkPusher struct{}
|
||||
|
||||
type barkPayload struct {
|
||||
DeviceKey string `json:"device_key"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Send 发送 Bark 通知
|
||||
func (p *BarkPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
deviceKey := cfg.Key
|
||||
@@ -51,59 +35,21 @@ func (p *BarkPusher) Send(ctx context.Context, cfg Config, target string, body m
|
||||
|
||||
serverURL := strings.TrimRight(cfg.URL, "/")
|
||||
if serverURL == "" {
|
||||
serverURL = defaultBarkServer
|
||||
serverURL = bark.DefaultServerURL
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
payload := barkPayload{
|
||||
DeviceKey: deviceKey,
|
||||
Title: title,
|
||||
Body: content,
|
||||
Group: "Wavelet",
|
||||
barkService := bark.NewWithServers(deviceKey, serverURL)
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(barkService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("bark: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
// 提取可选配置 (Ext 字段包含 group, sound, icon 等)
|
||||
if cfg.Ext != nil {
|
||||
if g, ok := cfg.Ext["group"].(string); ok && g != "" {
|
||||
payload.Group = g
|
||||
}
|
||||
if s, ok := cfg.Ext["sound"].(string); ok && s != "" {
|
||||
payload.Sound = s
|
||||
}
|
||||
if icon, ok := cfg.Ext["icon"].(string); ok && icon != "" {
|
||||
payload.Icon = icon
|
||||
}
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
pushURL := fmt.Sprintf("%s/push", serverURL)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushURL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("bark: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Bark 配置
|
||||
|
||||
@@ -11,12 +11,6 @@ import (
|
||||
)
|
||||
|
||||
func TestDingTalkPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("dingtalk")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get dingtalk pusher: %v", err)
|
||||
@@ -27,15 +21,9 @@ func TestDingTalkPusher(t *testing.T) {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
Secret: "test_secret",
|
||||
}, "", map[string]any{
|
||||
"title": "Alert",
|
||||
"content": "Server down",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,58 +57,36 @@ func TestBarkPusher(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDiscordPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("discord")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get discord pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://discord.com/api/webhooks/123/abc"})
|
||||
err = pusher.ValidateConfig(Config{Key: "bot_token_123"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
}, "", map[string]any{
|
||||
"title": "Discord Title",
|
||||
"content": "Discord Content",
|
||||
"level": "WARN",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("slack")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get slack pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://hooks.slack.com/services/123"})
|
||||
err = pusher.ValidateConfig(Config{Key: "xoxb-123456"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
}, "", map[string]any{
|
||||
"title": "Slack Title",
|
||||
"content": "Slack Content",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,3 +106,20 @@ func TestPushoverPusher(t *testing.T) {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("lark")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get lark pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,120 +4,62 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/dingding"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("dingtalk", &DingTalkPusher{})
|
||||
}
|
||||
|
||||
// DingTalkPusher 钉钉机器人 Webhook 推送实现
|
||||
// DingTalkPusher 基于 nikoksr/notify 的钉钉机器人推送实现
|
||||
type DingTalkPusher struct{}
|
||||
|
||||
type dingTalkMarkdown struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type dingTalkMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Markdown dingTalkMarkdown `json:"markdown"`
|
||||
}
|
||||
|
||||
// Send 发送钉钉通知
|
||||
func (p *DingTalkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("dingtalk: webhook URL is required")
|
||||
token := cfg.Key
|
||||
if token == "" {
|
||||
if u, err := url.Parse(cfg.URL); err == nil {
|
||||
token = u.Query().Get("access_token")
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
token = cfg.URL
|
||||
}
|
||||
if token == "" {
|
||||
return "", errors.New("dingtalk: access token or webhook URL is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n\n")
|
||||
|
||||
webhookURL := cfg.URL
|
||||
// 如果配置了签名 Secret (Key 或 Secret 字段),计算时间戳与签名
|
||||
secret := cfg.Secret
|
||||
if secret == "" {
|
||||
secret = cfg.Key
|
||||
}
|
||||
if secret != "" {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
stringToSign := timestamp + "\n" + secret
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
signature := url.QueryEscape(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
dingService := dingding.New(&dingding.Config{
|
||||
Token: token,
|
||||
Secret: cfg.Secret,
|
||||
})
|
||||
|
||||
sep := "?"
|
||||
if strings.Contains(webhookURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
webhookURL = fmt.Sprintf("%s%stimestamp=%s&sign=%s", webhookURL, sep, timestamp, signature)
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(dingService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("dingtalk: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
markdownText := fmt.Sprintf("### %s\n\n%s", title, content)
|
||||
msg := dingTalkMessage{
|
||||
MsgType: "markdown",
|
||||
Markdown: dingTalkMarkdown{
|
||||
Title: title,
|
||||
Text: markdownText,
|
||||
},
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dingtalk: marshal message failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dingtalk: create 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("dingtalk: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("dingtalk: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
var dingResp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &dingResp); err == nil && dingResp.ErrCode != 0 {
|
||||
return upstreamResp, fmt.Errorf("dingtalk: api error code %d: %s", dingResp.ErrCode, dingResp.ErrMsg)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验钉钉配置
|
||||
func (p *DingTalkPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
if cfg.URL == "" && cfg.Key == "" {
|
||||
return errors.New("webhook URL or access token is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
if cfg.URL != "" && !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must use https:// protocol")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -4,105 +4,69 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
discordColorInfo = 3447003 // Blue
|
||||
discordColorWarn = 15105570 // Orange
|
||||
discordColorErr = 15158332 // Red
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/discord"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("discord", &DiscordPusher{})
|
||||
}
|
||||
|
||||
// DiscordPusher Discord Webhook 机器人推送实现
|
||||
// DiscordPusher 基于 nikoksr/notify 的 Discord 推送实现
|
||||
type DiscordPusher struct{}
|
||||
|
||||
type discordEmbed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color int `json:"color"`
|
||||
}
|
||||
|
||||
type discordPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
|
||||
// Send 发送 Discord 通知
|
||||
func (p *DiscordPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("discord: webhook URL is required")
|
||||
func (p *DiscordPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
botToken := cfg.Key
|
||||
if botToken == "" {
|
||||
botToken = cfg.Secret
|
||||
}
|
||||
channelID := cfg.URL
|
||||
if target != "" {
|
||||
channelID = target
|
||||
}
|
||||
if channelID == "" {
|
||||
channelID = cfg.Other
|
||||
}
|
||||
|
||||
if botToken == "" {
|
||||
return "", errors.New("discord: bot token is required")
|
||||
}
|
||||
if channelID == "" {
|
||||
return "", errors.New("discord: channel ID is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n")
|
||||
level := bodyLevel(body)
|
||||
|
||||
color := discordColorInfo
|
||||
switch strings.ToUpper(level) {
|
||||
case "WARN", "WARNING":
|
||||
color = discordColorWarn
|
||||
case "ERROR", "FATAL":
|
||||
color = discordColorErr
|
||||
discordService := discord.New()
|
||||
if err := discordService.AuthenticateWithBotToken(botToken); err != nil {
|
||||
return "", fmt.Errorf("discord: auth failed: %w", err)
|
||||
}
|
||||
discordService.AddReceivers(channelID)
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(discordService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("discord: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
payload := discordPayload{
|
||||
Username: "Wavelet System",
|
||||
Embeds: []discordEmbed{
|
||||
{
|
||||
Title: title,
|
||||
Description: content,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discord: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discord: create 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("discord: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("discord: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Discord 配置
|
||||
func (p *DiscordPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
botToken := cfg.Key
|
||||
if botToken == "" {
|
||||
botToken = cfg.Secret
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must start with https://")
|
||||
if botToken == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,253 +4,49 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/lark"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("lark", &LarkPusher{})
|
||||
}
|
||||
|
||||
const (
|
||||
msgTypeInteractive = "interactive"
|
||||
)
|
||||
|
||||
// LarkPusher 飞书 Webhook 机器人推送实现
|
||||
// LarkPusher 基于 nikoksr/notify 的飞书 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) {
|
||||
// Send 发送飞书通知
|
||||
func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("lark: URL is required")
|
||||
return "", errors.New("lark: webhook URL is required")
|
||||
}
|
||||
|
||||
var req larkMessageRequest
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n")
|
||||
|
||||
// 1. 如果有自定义模板,我们尝试进行解析
|
||||
if template != "" {
|
||||
rendered := ParseTemplate(template, body)
|
||||
larkService := lark.NewWebhookService(cfg.URL)
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(larkService)
|
||||
|
||||
// 尝试解析原生的 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 := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n")
|
||||
level := bodyLevel(body)
|
||||
|
||||
// 根据级别确定飞书卡片头部的背景色模板
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("lark: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验飞书配置
|
||||
// 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://")
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must use https:// protocol")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func larkSign(secret string, timestamp int64) (string, error) {
|
||||
stringToSign := fmt.Sprintf("%v", timestamp) + "\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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Config struct {
|
||||
URL string `json:"url,omitempty"` // Webhook 地址或 SMTP 地址
|
||||
Secret string `json:"secret,omitempty"` // 签名密钥或 SMTP 密码/Token
|
||||
Key string `json:"key,omitempty"` // AppID 或 SMTP 用户名
|
||||
Other string `json:"other,omitempty"` // 附加配置 (如 ChatID / UserKey / 扩展 JSON)
|
||||
Ext map[string]any `json:"ext,omitempty"` // 预留拓展 JSON 配置
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,19 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
pushoverAPIEndpoint = "https://api.pushover.net/1/messages.json"
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/pushover"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("pushover", &PushoverPusher{})
|
||||
}
|
||||
|
||||
// PushoverPusher Pushover 移动端推送实现
|
||||
// PushoverPusher 基于 nikoksr/notify 的 Pushover 移动端推送实现
|
||||
type PushoverPusher struct{}
|
||||
|
||||
// Send 发送 Pushover 通知
|
||||
@@ -36,10 +30,8 @@ func (p *PushoverPusher) Send(ctx context.Context, cfg Config, target string, bo
|
||||
}
|
||||
|
||||
userKey := cfg.URL
|
||||
if userKey == "" && cfg.Ext != nil {
|
||||
if k, ok := cfg.Ext["user_key"].(string); ok {
|
||||
userKey = k
|
||||
}
|
||||
if userKey == "" {
|
||||
userKey = cfg.Other
|
||||
}
|
||||
if target != "" {
|
||||
userKey = target
|
||||
@@ -51,33 +43,17 @@ func (p *PushoverPusher) Send(ctx context.Context, cfg Config, target string, bo
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("token", appToken)
|
||||
formData.Set("user", userKey)
|
||||
formData.Set("title", title)
|
||||
formData.Set("message", content)
|
||||
poService := pushover.New(appToken)
|
||||
poService.AddReceivers(userKey)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushoverAPIEndpoint, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pushover: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(poService)
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pushover: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("pushover: http status %s", resp.Status)
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("pushover: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Pushover 配置
|
||||
|
||||
@@ -4,77 +4,66 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/slack"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("slack", &SlackPusher{})
|
||||
}
|
||||
|
||||
// SlackPusher Slack Webhook 推送实现
|
||||
// SlackPusher 基于 nikoksr/notify 的 Slack 推送实现
|
||||
type SlackPusher struct{}
|
||||
|
||||
type slackPayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Send 发送 Slack 通知
|
||||
func (p *SlackPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("slack: webhook URL is required")
|
||||
func (p *SlackPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
token := cfg.Key
|
||||
if token == "" {
|
||||
token = cfg.Secret
|
||||
}
|
||||
channelID := cfg.URL
|
||||
if target != "" {
|
||||
channelID = target
|
||||
}
|
||||
if channelID == "" {
|
||||
channelID = cfg.Other
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", errors.New("slack: bot/api token is required")
|
||||
}
|
||||
if channelID == "" {
|
||||
return "", errors.New("slack: channel ID is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "*%s*: %v", "\n")
|
||||
|
||||
text := fmt.Sprintf("*%s*\n%s", title, content)
|
||||
payload := slackPayload{
|
||||
Text: text,
|
||||
slackService := slack.New(token)
|
||||
slackService.AddReceivers(channelID)
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(slackService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("slack: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack: create 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("slack: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("slack: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Slack 配置
|
||||
func (p *SlackPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
token := cfg.Key
|
||||
if token == "" {
|
||||
token = cfg.Secret
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must start with https://")
|
||||
if token == "" {
|
||||
return errors.New("slack token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,137 +4,72 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/telegram"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("telegram", &TelegramPusher{})
|
||||
}
|
||||
|
||||
// TelegramPusher Telegram 机器人推送实现
|
||||
// TelegramPusher 基于 nikoksr/notify 的 Telegram 机器人推送实现
|
||||
type TelegramPusher struct{}
|
||||
|
||||
type telegramMessageRequest struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type telegramErrorResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Send 执行 Telegram 消息发送
|
||||
func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, _ map[string]any) (string, error) {
|
||||
if cfg.Secret == "" {
|
||||
return "", errors.New("telegram: Bot Token (Secret) is required")
|
||||
func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
botToken := cfg.Secret
|
||||
if botToken == "" {
|
||||
botToken = cfg.Key
|
||||
}
|
||||
if botToken == "" {
|
||||
return "", errors.New("telegram: bot token is required")
|
||||
}
|
||||
|
||||
chatID := target
|
||||
if chatID == "" {
|
||||
chatID = cfg.Key // Use default chat ID (Key) if target is blank
|
||||
chatIDStr := target
|
||||
if chatIDStr == "" {
|
||||
chatIDStr = cfg.Other
|
||||
}
|
||||
if chatID == "" {
|
||||
return "", errors.New("telegram: chat_id (target or default Key) is required")
|
||||
if chatIDStr == "" {
|
||||
return "", errors.New("telegram: chat_id is required")
|
||||
}
|
||||
|
||||
baseURL := cfg.URL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.telegram.org"
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("telegram: invalid chat_id %q: %w", chatIDStr, err)
|
||||
}
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "<b>%s</b>: %v", "\n")
|
||||
level := bodyLevel(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
var text string
|
||||
if template != "" {
|
||||
text = ParseTemplate(template, body)
|
||||
} else {
|
||||
text = fmt.Sprintf("<b>[%s] %s</b>\n\n%s", escapeHTML(level), escapeHTML(title), escapeHTML(content))
|
||||
}
|
||||
|
||||
// Try sending with HTML parse mode
|
||||
err := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, text, "HTML")
|
||||
tgService, err := telegram.New(botToken)
|
||||
if err != nil {
|
||||
// Fallback: send as plain text without parse mode
|
||||
plainText := text
|
||||
if template == "" {
|
||||
plainText = fmt.Sprintf("[%s] %s\n\n%s", level, title, content)
|
||||
}
|
||||
fallbackErr := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, plainText, "")
|
||||
if fallbackErr != nil {
|
||||
return "", fmt.Errorf("telegram: send message failed (fallback also failed): %w (original HTML error: %w)", fallbackErr, err)
|
||||
}
|
||||
return "", fmt.Errorf("telegram: init service failed: %w", err)
|
||||
}
|
||||
tgService.AddReceivers(chatID)
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(tgService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("telegram: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Telegram 配置
|
||||
// ValidateConfig 校验 Telegram 机器人配置
|
||||
func (p *TelegramPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.Secret == "" {
|
||||
return errors.New("bot Token (Secret) is required")
|
||||
token := cfg.Secret
|
||||
if token == "" {
|
||||
token = cfg.Key
|
||||
}
|
||||
if cfg.URL != "" {
|
||||
if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("API base URL must start with http:// or https://")
|
||||
}
|
||||
if token == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TelegramPusher) sendMessage(ctx context.Context, baseURL, token, chatID, text, parseMode string) error {
|
||||
apiURL := fmt.Sprintf("%s/bot%s/sendMessage", baseURL, token)
|
||||
|
||||
reqPayload := telegramMessageRequest{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: parseMode,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("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("http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errRes telegramErrorResponse
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&errRes); decodeErr == nil {
|
||||
return fmt.Errorf("http status %d: %s", resp.StatusCode, errRes.Description)
|
||||
}
|
||||
return fmt.Errorf("http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -5,112 +5,24 @@ package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTelegramPusher_Send(t *testing.T) {
|
||||
t.Run("successful send with HTML parse mode", func(t *testing.T) {
|
||||
var receivedReq telegramMessageRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/botmy-token/sendMessage", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
func TestTelegramPusherValidation(t *testing.T) {
|
||||
pusher := &TelegramPusher{}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&receivedReq)
|
||||
require.NoError(t, err)
|
||||
err := pusher.ValidateConfig(Config{Secret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
|
||||
pusher := &TelegramPusher{}
|
||||
cfg := Config{
|
||||
Channel: "telegram",
|
||||
URL: server.URL,
|
||||
Secret: "my-token",
|
||||
}
|
||||
body := map[string]any{
|
||||
"title": "Alert",
|
||||
"content": "Host down",
|
||||
"level": "CRITICAL",
|
||||
}
|
||||
_, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "123456", receivedReq.ChatID)
|
||||
assert.Contains(t, receivedReq.Text, "[CRITICAL] Alert")
|
||||
assert.Contains(t, receivedReq.Text, "Host down")
|
||||
assert.Equal(t, "HTML", receivedReq.ParseMode)
|
||||
})
|
||||
|
||||
t.Run("fallback to plain text on HTML error", func(t *testing.T) {
|
||||
var requests []*telegramMessageRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramMessageRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
requests = append(requests, &req)
|
||||
|
||||
if len(requests) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"ok": false, "error_code": 400, "description": "Bad Request: can't parse entities"}`))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher := &TelegramPusher{}
|
||||
cfg := Config{
|
||||
Channel: "telegram",
|
||||
URL: server.URL,
|
||||
Secret: "my-token",
|
||||
}
|
||||
body := map[string]any{
|
||||
"title": "Alert & Info",
|
||||
"content": "A < B comparison",
|
||||
"level": "INFO",
|
||||
}
|
||||
_, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, requests, 2)
|
||||
assert.Equal(t, "HTML", requests[0].ParseMode)
|
||||
assert.Equal(t, "", requests[1].ParseMode)
|
||||
assert.Contains(t, requests[1].Text, "[INFO] Alert & Info")
|
||||
assert.Contains(t, requests[1].Text, "A < B comparison")
|
||||
})
|
||||
|
||||
t.Run("validation error", func(t *testing.T) {
|
||||
pusher := &TelegramPusher{}
|
||||
cfg := Config{
|
||||
Channel: "telegram",
|
||||
URL: "https://api.telegram.org",
|
||||
}
|
||||
err := pusher.ValidateConfig(cfg)
|
||||
assert.Error(t, err)
|
||||
|
||||
cfg = Config{
|
||||
Channel: "telegram",
|
||||
URL: "ftp://api.telegram.org",
|
||||
Secret: "token",
|
||||
}
|
||||
err = pusher.ValidateConfig(cfg)
|
||||
assert.Error(t, err)
|
||||
|
||||
cfg = Config{
|
||||
Channel: "telegram",
|
||||
Secret: "token",
|
||||
}
|
||||
err = pusher.ValidateConfig(cfg)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
_, err = pusher.Send(context.Background(), Config{Secret: "123:token"}, "not-a-number", map[string]any{"title": "test"}, "", nil)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid chat_id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,11 +344,3 @@ func bodyContent(body map[string]any, format, sep string) string {
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// bodyLevel returns the upper-cased notification level, falling back to INFO.
|
||||
func bodyLevel(body map[string]any) string {
|
||||
if l, ok := body["level"].(string); ok && l != "" {
|
||||
return strings.ToUpper(l)
|
||||
}
|
||||
return levelInfo
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user