mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
refactor(msg_gateway): restructure and rename message_gateway aligned with custom_example
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/bark"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("bark", &BarkPusher{})
|
||||
}
|
||||
|
||||
// BarkPusher 基于 nikoksr/notify 的 Bark iOS 客户端通知推送实现
|
||||
type BarkPusher struct{}
|
||||
|
||||
// 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
|
||||
if deviceKey == "" {
|
||||
deviceKey = cfg.Secret
|
||||
}
|
||||
if target != "" {
|
||||
deviceKey = target
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return "", errors.New("bark: device key is required")
|
||||
}
|
||||
|
||||
serverURL := strings.TrimRight(cfg.URL, "/")
|
||||
if serverURL == "" {
|
||||
serverURL = bark.DefaultServerURL
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Bark 配置
|
||||
func (p *BarkPusher) ValidateConfig(cfg Config) error {
|
||||
deviceKey := cfg.Key
|
||||
if deviceKey == "" {
|
||||
deviceKey = cfg.Secret
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return errors.New("device key is required")
|
||||
}
|
||||
if cfg.URL != "" && !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("server URL must start with http:// or https://")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDingTalkPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("dingtalk")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get dingtalk pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://oapi.dingtalk.com/robot/send?access_token=test"})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarkPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("bark")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get bark pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "device_key_123"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
Key: "device_key_123",
|
||||
}, "", map[string]any{
|
||||
"title": "Alert",
|
||||
"content": "Bark notification",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("discord")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get discord pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "bot_token_123"})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("slack")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get slack pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "xoxb-123456"})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushoverPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("pushover")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get pushover pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "app_token_123"})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomPusherSend_ResponseBodyErrcode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "wechat business error returns HTTP 200 with non-zero errcode",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"errcode":93000,"errmsg":"invalid request data"}`,
|
||||
wantErr: true,
|
||||
wantErrMsg: "errcode=93000",
|
||||
},
|
||||
{
|
||||
name: "wechat success returns errcode 0",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"errcode":0,"errmsg":"ok"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json response without errcode is tolerated",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"success":true}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-json response body is tolerated",
|
||||
statusCode: http.StatusOK,
|
||||
body: "ok",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty response body is tolerated",
|
||||
statusCode: http.StatusNoContent,
|
||||
body: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "http error status still fails",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
body: `{"errcode":0,"errmsg":"ok"}`,
|
||||
wantErr: true,
|
||||
wantErrMsg: "http status",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
pusher := &CustomPusher{}
|
||||
upstreamResp, err := pusher.Send(context.Background(),
|
||||
Config{Channel: "custom", URL: srv.URL},
|
||||
"",
|
||||
map[string]any{"title": "t", "content": "c"},
|
||||
`{"title":"$title","content":"$content"}`,
|
||||
nil,
|
||||
)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErrMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
if tt.body != "" {
|
||||
assert.Contains(t, upstreamResp, tt.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/dingding"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("dingtalk", &DingTalkPusher{})
|
||||
}
|
||||
|
||||
// DingTalkPusher 基于 nikoksr/notify 的钉钉机器人推送实现
|
||||
type DingTalkPusher struct{}
|
||||
|
||||
// Send 发送钉钉通知
|
||||
func (p *DingTalkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
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")
|
||||
|
||||
dingService := dingding.New(&dingding.Config{
|
||||
Token: token,
|
||||
Secret: cfg.Secret,
|
||||
})
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(dingService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("dingtalk: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验钉钉配置
|
||||
func (p *DingTalkPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" && cfg.Key == "" {
|
||||
return errors.New("webhook URL or access token is required")
|
||||
}
|
||||
if cfg.URL != "" && !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must use https:// protocol")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/discord"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("discord", &DiscordPusher{})
|
||||
}
|
||||
|
||||
// DiscordPusher 基于 nikoksr/notify 的 Discord 推送实现
|
||||
type DiscordPusher struct{}
|
||||
|
||||
// Send 发送 Discord 通知
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Discord 配置
|
||||
func (p *DiscordPusher) ValidateConfig(cfg Config) error {
|
||||
botToken := cfg.Key
|
||||
if botToken == "" {
|
||||
botToken = cfg.Secret
|
||||
}
|
||||
if botToken == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
pkgmail "Wavelet/pkg/mail"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("email", &EmailPusher{})
|
||||
}
|
||||
|
||||
// EmailPusher 基于 pkg/mail 的 SMTP 邮件推送实现
|
||||
type EmailPusher struct{}
|
||||
|
||||
// Send 发送邮件
|
||||
func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) (string, error) {
|
||||
if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" {
|
||||
return "", errors.New("email: SMTP configuration (url, key, secret) is incomplete")
|
||||
}
|
||||
if target == "" {
|
||||
return "", errors.New("email: target email address is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "<p><b>%s</b>: %v</p>", "")
|
||||
|
||||
fromName := "System Notification"
|
||||
if ext != nil {
|
||||
if fn, ok := ext["from_name"].(string); ok && fn != "" {
|
||||
fromName = fn
|
||||
}
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`<html><body><h2>%s</h2><div>%s</div></body></html>`, title, content)
|
||||
|
||||
host, portStr, err := net.SplitHostPort(cfg.URL)
|
||||
port := 25
|
||||
if err != nil {
|
||||
host = cfg.URL
|
||||
} else if p, err := strconv.Atoi(portStr); err == nil && p > 0 {
|
||||
port = p
|
||||
}
|
||||
|
||||
mailCfg := pkgmail.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: cfg.Key,
|
||||
Password: cfg.Secret,
|
||||
FromName: fromName,
|
||||
}
|
||||
|
||||
if err := pkgmail.SendMail(ctx, mailCfg, target, title, htmlBody); err != nil {
|
||||
return "", fmt.Errorf("email: send smtp mail failed: %w", err)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验邮件 SMTP 配置
|
||||
func (p *EmailPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("SMTP host:port is required")
|
||||
}
|
||||
if cfg.Key == "" {
|
||||
return errors.New("SMTP username is required")
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
return errors.New("SMTP password is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmailPusherValidateConfig(t *testing.T) {
|
||||
pusher := &EmailPusher{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty url",
|
||||
cfg: Config{URL: "", Key: "user", Secret: "pass"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
cfg: Config{URL: "smtp.example.com:587", Key: "", Secret: "pass"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
cfg: Config{URL: "smtp.example.com:587", Key: "user", Secret: ""},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: Config{URL: "smtp.example.com:587", Key: "user", Secret: "pass"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := pusher.ValidateConfig(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailPusherSendValidation(t *testing.T) {
|
||||
pusher := &EmailPusher{}
|
||||
|
||||
// Missing target
|
||||
_, err := pusher.Send(context.Background(), Config{URL: "127.0.0.1:25", Key: "u", Secret: "p"}, "", map[string]any{"title": "hi"}, "", nil)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty target, got nil")
|
||||
}
|
||||
|
||||
// Missing config
|
||||
_, err = pusher.Send(context.Background(), Config{}, "test@example.com", map[string]any{"title": "hi"}, "", nil)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/lark"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("lark", &LarkPusher{})
|
||||
}
|
||||
|
||||
// LarkPusher 基于 nikoksr/notify 的飞书 Webhook 机器人推送实现
|
||||
type LarkPusher struct{}
|
||||
|
||||
// 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: webhook URL is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n")
|
||||
|
||||
larkService := lark.NewWebhookService(cfg.URL)
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(larkService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("lark: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验飞书机器人配置
|
||||
func (p *LarkPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must use https:// protocol")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package push 提供解耦的、无外部业务依赖 of 通知推送底层实现
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTitle = "系统通知"
|
||||
levelInfo = "INFO"
|
||||
defaultHTTPClientTimeout = 10 * time.Second
|
||||
maxResponseBodyBytes = 4096
|
||||
)
|
||||
|
||||
// Config 基础通知渠道配置
|
||||
type Config struct {
|
||||
Channel string `json:"channel"` // 渠道名称,例如 "lark", "custom", "email" 等,唯一标识
|
||||
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 配置
|
||||
}
|
||||
|
||||
// Pusher 通知推送渠道接口
|
||||
type Pusher interface {
|
||||
// Send 发送通知消息
|
||||
// target: 发送目标 (如邮箱地址或特定用户标识;若为 bot 机器人此项为空)
|
||||
// body: 消息体数据 (含默认字段如 title, content, level)
|
||||
// template: 消息卡片/模板 JSON (可选)
|
||||
// ext: 预留的单次发送拓展数据
|
||||
// 返回 upstreamResp: 上游服务返回的响应内容(如 Webhook 响应体),用于任务日志审计;无响应时为空字符串
|
||||
Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, ext map[string]any) (upstreamResp string, err error)
|
||||
|
||||
// ValidateConfig 校验渠道配置合法性
|
||||
ValidateConfig(cfg Config) error
|
||||
}
|
||||
|
||||
var (
|
||||
pushersMu sync.RWMutex
|
||||
pushers = make(map[string]Pusher)
|
||||
)
|
||||
|
||||
// Register 注册一个推送渠道实现
|
||||
func Register(channelType string, pusher Pusher) {
|
||||
pushersMu.Lock()
|
||||
defer pushersMu.Unlock()
|
||||
if pusher == nil {
|
||||
panic("push: Register pusher is nil")
|
||||
}
|
||||
pushers[channelType] = pusher
|
||||
}
|
||||
|
||||
// GetPusher 获取指定类型的推送渠道实现
|
||||
func GetPusher(channelType string) (Pusher, error) {
|
||||
pushersMu.RLock()
|
||||
defer pushersMu.RUnlock()
|
||||
pusher, ok := pushers[channelType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("push: unknown channel type %q", channelType)
|
||||
}
|
||||
return pusher, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/pushover"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("pushover", &PushoverPusher{})
|
||||
}
|
||||
|
||||
// PushoverPusher 基于 nikoksr/notify 的 Pushover 移动端推送实现
|
||||
type PushoverPusher struct{}
|
||||
|
||||
// Send 发送 Pushover 通知
|
||||
func (p *PushoverPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
appToken := cfg.Key
|
||||
if appToken == "" {
|
||||
appToken = cfg.Secret
|
||||
}
|
||||
if appToken == "" {
|
||||
return "", errors.New("pushover: app token is required")
|
||||
}
|
||||
|
||||
userKey := cfg.URL
|
||||
if userKey == "" {
|
||||
userKey = cfg.Other
|
||||
}
|
||||
if target != "" {
|
||||
userKey = target
|
||||
}
|
||||
if userKey == "" {
|
||||
return "", errors.New("pushover: user key is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
poService := pushover.New(appToken)
|
||||
poService.AddReceivers(userKey)
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(poService)
|
||||
|
||||
if err := notifier.Send(ctx, title, content); err != nil {
|
||||
return "", fmt.Errorf("pushover: notify send failed: %w", err)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Pushover 配置
|
||||
func (p *PushoverPusher) ValidateConfig(cfg Config) error {
|
||||
appToken := cfg.Key
|
||||
if appToken == "" {
|
||||
appToken = cfg.Secret
|
||||
}
|
||||
if appToken == "" {
|
||||
return errors.New("app token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/slack"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("slack", &SlackPusher{})
|
||||
}
|
||||
|
||||
// SlackPusher 基于 nikoksr/notify 的 Slack 推送实现
|
||||
type SlackPusher struct{}
|
||||
|
||||
// Send 发送 Slack 通知
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Slack 配置
|
||||
func (p *SlackPusher) ValidateConfig(cfg Config) error {
|
||||
token := cfg.Key
|
||||
if token == "" {
|
||||
token = cfg.Secret
|
||||
}
|
||||
if token == "" {
|
||||
return errors.New("slack token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/nikoksr/notify"
|
||||
"github.com/nikoksr/notify/service/telegram"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("telegram", &TelegramPusher{})
|
||||
}
|
||||
|
||||
// TelegramPusher 基于 nikoksr/notify 的 Telegram 机器人推送实现
|
||||
type TelegramPusher struct{}
|
||||
|
||||
// Send 执行 Telegram 消息发送
|
||||
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")
|
||||
}
|
||||
|
||||
chatIDStr := target
|
||||
if chatIDStr == "" {
|
||||
chatIDStr = cfg.Other
|
||||
}
|
||||
if chatIDStr == "" {
|
||||
return "", errors.New("telegram: chat_id is required")
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("telegram: invalid chat_id %q: %w", chatIDStr, err)
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
tgService, err := telegram.New(botToken)
|
||||
if err != nil {
|
||||
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 "ok", nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Telegram 机器人配置
|
||||
func (p *TelegramPusher) ValidateConfig(cfg Config) error {
|
||||
token := cfg.Secret
|
||||
if token == "" {
|
||||
token = cfg.Key
|
||||
}
|
||||
if token == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTelegramPusherValidation(t *testing.T) {
|
||||
pusher := &TelegramPusher{}
|
||||
|
||||
err := pusher.ValidateConfig(Config{Secret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"})
|
||||
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")
|
||||
}
|
||||
|
||||
_, 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// placeholderRegex matches {{ ... }} tags
|
||||
placeholderRegex = regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
|
||||
// identifierRegex matches simple identifiers like name or user.username
|
||||
identifierRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.]*$`)
|
||||
)
|
||||
|
||||
// jsonMap is a map that serializes to JSON when printed as a string in templates.
|
||||
type jsonMap map[string]any
|
||||
|
||||
func (m jsonMap) String() string {
|
||||
b, err := json.Marshal(map[string]any(m))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", map[string]any(m))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (m jsonMap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]any(m))
|
||||
}
|
||||
|
||||
// jsonSlice is a slice that serializes to JSON when printed as a string in templates.
|
||||
type jsonSlice []any
|
||||
|
||||
func (s jsonSlice) String() string {
|
||||
b, err := json.Marshal([]any(s))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", []any(s))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s jsonSlice) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]any(s))
|
||||
}
|
||||
|
||||
var defaultFuncMap = template.FuncMap{
|
||||
"default": func(fallback any, val any) any {
|
||||
if val == nil {
|
||||
return fallback
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
case bool:
|
||||
if !v {
|
||||
return fallback
|
||||
}
|
||||
case int:
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
case int32:
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
case int64:
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
case float64:
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return val
|
||||
},
|
||||
"toJson": func(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return string(b)
|
||||
},
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
"trim": strings.TrimSpace,
|
||||
"dateFormat": func(format string, t any) string {
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
return v.Format(format)
|
||||
case *time.Time:
|
||||
if v != nil {
|
||||
return v.Format(format)
|
||||
}
|
||||
}
|
||||
return fmt.Sprint(t)
|
||||
},
|
||||
}
|
||||
|
||||
// hasKey checks if a dot-delimited or plain key exists in body
|
||||
func hasKey(body map[string]any, key string) bool {
|
||||
if _, ok := body[key]; ok {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
var cur any = body
|
||||
for _, part := range parts {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
val, exists := m[part]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
cur = val
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeTemplate converts legacy {{key}} / {{user.name}} into Go template {{.user.name}}
|
||||
// while preserving Go template keywords, dot expressions, pipelines, and missing placeholders.
|
||||
func normalizeTemplate(tmpl string, body map[string]any) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(tmpl, func(match string) string {
|
||||
sub := strings.TrimSpace(match[2 : len(match)-2])
|
||||
if sub == "" {
|
||||
return match
|
||||
}
|
||||
// If it's already a dot expression or special variable ($...)
|
||||
if strings.HasPrefix(sub, ".") || strings.HasPrefix(sub, "$") {
|
||||
return match
|
||||
}
|
||||
// If it's a known Go template keyword or block
|
||||
firstWord := strings.Fields(sub)[0]
|
||||
switch firstWord {
|
||||
case "if", "else", "end", "range", "with", "template", "define", "block", "nil", "true", "false":
|
||||
return match
|
||||
}
|
||||
// Check if it's a pipeline like `key | default "val"`
|
||||
if strings.Contains(sub, "|") {
|
||||
const pipelineSplitCount = 2
|
||||
parts := strings.SplitN(sub, "|", pipelineSplitCount)
|
||||
left := strings.TrimSpace(parts[0])
|
||||
right := strings.TrimSpace(parts[1])
|
||||
if identifierRegex.MatchString(left) && !strings.HasPrefix(left, ".") && !strings.HasPrefix(left, "$") {
|
||||
return fmt.Sprintf("{{ .%s | %s }}", left, right)
|
||||
}
|
||||
return match
|
||||
}
|
||||
// Simple identifier: if present in body, convert to dot expression; otherwise preserve as is for fallback
|
||||
if identifierRegex.MatchString(sub) {
|
||||
if hasKey(body, sub) {
|
||||
return fmt.Sprintf("{{ .%s }}", sub)
|
||||
}
|
||||
// Missing key: keep original text so fallback or literal is preserved
|
||||
return match
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// prepareContext pre-processes the body map so that:
|
||||
// 1. Dotted keys like "user.name" are expanded to nested map structure.
|
||||
// 2. Complex structs, slices, and maps have JSON-friendly string representations when directly interpolated.
|
||||
func prepareContext(body map[string]any) jsonMap {
|
||||
if body == nil {
|
||||
return make(jsonMap)
|
||||
}
|
||||
ctx := make(jsonMap, len(body))
|
||||
for k, v := range body {
|
||||
formatted := formatContextValue(v)
|
||||
ctx[k] = formatted
|
||||
// If key contains '.', expand into nested hierarchy
|
||||
if strings.Contains(k, ".") {
|
||||
parts := strings.Split(k, ".")
|
||||
cur := ctx
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
sub, ok := cur[parts[i]].(jsonMap)
|
||||
if !ok {
|
||||
sub = make(jsonMap)
|
||||
cur[parts[i]] = sub
|
||||
}
|
||||
cur = sub
|
||||
}
|
||||
cur[parts[len(parts)-1]] = formatted
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// formatContextValue formats slices and maps to JSON representation for direct string printing,
|
||||
// while preserving basic scalar types for template functions.
|
||||
func formatContextValue(v any) any {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool, time.Time:
|
||||
return val
|
||||
case []byte:
|
||||
return string(val)
|
||||
case map[string]any:
|
||||
jm := make(jsonMap, len(val))
|
||||
for k, subVal := range val {
|
||||
jm[k] = formatContextValue(subVal)
|
||||
}
|
||||
return jm
|
||||
case []any:
|
||||
js := make(jsonSlice, len(val))
|
||||
for i, subVal := range val {
|
||||
js[i] = formatContextValue(subVal)
|
||||
}
|
||||
return js
|
||||
case []string:
|
||||
js := make(jsonSlice, len(val))
|
||||
for i, subVal := range val {
|
||||
js[i] = subVal
|
||||
}
|
||||
return js
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body.
|
||||
// It supports Go text/template expressions (e.g. if/else, pipelines, default, toJson) as well as legacy {{key}} placeholders.
|
||||
func ParseTemplate(templateStr string, body map[string]any) string {
|
||||
if templateStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized := normalizeTemplate(templateStr, body)
|
||||
ctx := prepareContext(body)
|
||||
|
||||
tmpl, err := template.New("push_tmpl").
|
||||
Funcs(defaultFuncMap).
|
||||
Option("missingkey=zero").
|
||||
Parse(normalized)
|
||||
if err != nil {
|
||||
return fallbackReplace(templateStr, body)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.Execute(&buf, ctx); err != nil {
|
||||
return fallbackReplace(templateStr, body)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func fallbackReplace(template string, body map[string]any) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(template))
|
||||
|
||||
i := 0
|
||||
for {
|
||||
pos := strings.Index(template[i:], "{{")
|
||||
if pos == -1 {
|
||||
buf.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
buf.WriteString(template[i : i+pos])
|
||||
i += pos + 2
|
||||
|
||||
endPos := strings.Index(template[i:], "}}")
|
||||
if endPos == -1 {
|
||||
buf.WriteString("{{")
|
||||
buf.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
key := strings.TrimSpace(template[i : i+endPos])
|
||||
key = strings.TrimPrefix(key, ".")
|
||||
if val, ok := body[key]; ok {
|
||||
buf.WriteString(formatValue(val))
|
||||
} else {
|
||||
buf.WriteString("{{")
|
||||
buf.WriteString(template[i : i+endPos])
|
||||
buf.WriteString("}}")
|
||||
}
|
||||
i += endPos + 2
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func formatValue(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case []byte:
|
||||
return string(val)
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(val)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// bodyTitle returns the notification title, falling back to the default.
|
||||
func bodyTitle(body map[string]any) string {
|
||||
if t, ok := body["title"].(string); ok && t != "" {
|
||||
return t
|
||||
}
|
||||
return defaultTitle
|
||||
}
|
||||
|
||||
// bodyContent returns the notification body, rendering every entry with format
|
||||
// (a "%s … %v" pair) and joining them with sep when no content field is given.
|
||||
// Entries render in sorted key order so identical bodies always produce
|
||||
// identical text.
|
||||
func bodyContent(body map[string]any, format, sep string) string {
|
||||
if c, ok := body["content"].(string); ok && c != "" {
|
||||
return c
|
||||
}
|
||||
parts := make([]string, 0, len(body))
|
||||
for _, k := range slices.Sorted(maps.Keys(body)) {
|
||||
parts = append(parts, fmt.Sprintf(format, k, body[k]))
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
body map[string]any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple replacement",
|
||||
template: "hello {{name}}",
|
||||
body: map[string]any{"name": "world"},
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "multiple replacements",
|
||||
template: "{{greeting}} {{name}}!",
|
||||
body: map[string]any{"greeting": "Hello", "name": "Alice"},
|
||||
expected: "Hello Alice!",
|
||||
},
|
||||
{
|
||||
name: "missing key preserves placeholder",
|
||||
template: "hello {{name}} and {{other}}",
|
||||
body: map[string]any{"name": "world"},
|
||||
expected: "hello world and {{other}}",
|
||||
},
|
||||
{
|
||||
name: "unbalanced placeholders",
|
||||
template: "hello {{name",
|
||||
body: map[string]any{"name": "world"},
|
||||
expected: "hello {{name",
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
template: "val: {{val}}",
|
||||
body: map[string]any{"val": nil},
|
||||
expected: "val: ",
|
||||
},
|
||||
{
|
||||
name: "basic types",
|
||||
template: "int: {{i}}, float: {{f}}, bool: {{b}}",
|
||||
body: map[string]any{"i": 123, "f": 45.67, "b": true},
|
||||
expected: "int: 123, float: 45.67, bool: true",
|
||||
},
|
||||
{
|
||||
name: "complex type slice",
|
||||
template: "items: {{items}}",
|
||||
body: map[string]any{"items": []string{"a", "b"}},
|
||||
expected: `items: ["a","b"]`,
|
||||
},
|
||||
{
|
||||
name: "complex type map",
|
||||
template: "obj: {{obj}}",
|
||||
body: map[string]any{"obj": map[string]any{"key": "value"}},
|
||||
expected: `obj: {"key":"value"}`,
|
||||
},
|
||||
{
|
||||
name: "nested property from flat map",
|
||||
template: "hello {{user.username}}",
|
||||
body: map[string]any{"user.username": "Alice"},
|
||||
expected: "hello Alice",
|
||||
},
|
||||
{
|
||||
name: "nested property from nested map",
|
||||
template: "hello {{user.username}}",
|
||||
body: map[string]any{"user": map[string]any{"username": "Bob"}},
|
||||
expected: "hello Bob",
|
||||
},
|
||||
{
|
||||
name: "go template dot syntax",
|
||||
template: "hello {{.user.username}}",
|
||||
body: map[string]any{"user": map[string]any{"username": "Charlie"}},
|
||||
expected: "hello Charlie",
|
||||
},
|
||||
{
|
||||
name: "default value helper fallback",
|
||||
template: "hello {{.nickname | default \"Guest\"}}",
|
||||
body: map[string]any{"nickname": ""},
|
||||
expected: "hello Guest",
|
||||
},
|
||||
{
|
||||
name: "default value helper provided",
|
||||
template: "hello {{.nickname | default \"Guest\"}}",
|
||||
body: map[string]any{"nickname": "David"},
|
||||
expected: "hello David",
|
||||
},
|
||||
{
|
||||
name: "conditional if else true",
|
||||
template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}",
|
||||
body: map[string]any{"is_admin": true, "name": "Eve"},
|
||||
expected: "Admin: Eve",
|
||||
},
|
||||
{
|
||||
name: "conditional if else false",
|
||||
template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}",
|
||||
body: map[string]any{"is_admin": false, "name": "Frank"},
|
||||
expected: "User: Frank",
|
||||
},
|
||||
{
|
||||
name: "upper and lower helper",
|
||||
template: "{{.title | upper}} - {{.level | lower}}",
|
||||
body: map[string]any{"title": "Warning", "level": "INFO"},
|
||||
expected: "WARNING - info",
|
||||
},
|
||||
{
|
||||
name: "toJson helper",
|
||||
template: "payload: {{toJson .data}}",
|
||||
body: map[string]any{"data": map[string]any{"status": "ok"}},
|
||||
expected: `payload: {"status":"ok"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseTemplate(tt.template, tt.body)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesized content must render in a stable order, otherwise two identical
|
||||
// notifications produce different text on every send.
|
||||
func TestBodyContentFallbackIsDeterministic(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"zebra": 1,
|
||||
"alpha": 2,
|
||||
"mike": 3,
|
||||
"charlie": 4,
|
||||
"yankee": 5,
|
||||
}
|
||||
|
||||
first := bodyContent(body, "%s=%v", ",")
|
||||
for i := 1; i <= 50; i++ {
|
||||
if got := bodyContent(body, "%s=%v", ","); got != first {
|
||||
t.Fatalf("bodyContent order changed on call %d: %q != %q", i, got, first)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, "alpha=2,charlie=4,mike=3,yankee=5,zebra=1", first)
|
||||
}
|
||||
Reference in New Issue
Block a user