refactor: notifications

This commit is contained in:
yusing 2025-05-02 05:51:15 +08:00
parent 28d9a72908
commit 69ee8495d8
11 changed files with 202 additions and 104 deletions

View file

@ -10,9 +10,10 @@ import (
)
type ProviderBase struct {
Name string `json:"name" validate:"required"`
URL string `json:"url" validate:"url"`
Token string `json:"token"`
Name string `json:"name" validate:"required"`
URL string `json:"url" validate:"url"`
Token string `json:"token"`
Format *LogFormat `json:"format"`
}
var (
@ -22,8 +23,8 @@ var (
// Validate implements the utils.CustomValidator interface.
func (base *ProviderBase) Validate() gperr.Error {
if base.Token == "" {
return ErrMissingToken
if base.Format == nil {
base.Format = LogFormatMarkdown
}
if !strings.HasPrefix(base.URL, "http://") && !strings.HasPrefix(base.URL, "https://") {
return ErrURLMissingScheme

111
internal/notif/body.go Normal file
View file

@ -0,0 +1,111 @@
package notif
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/yusing/go-proxy/internal/gperr"
)
type (
LogField struct {
Name string `json:"name"`
Value string `json:"value"`
}
LogFormat struct {
string
}
LogBody interface {
Format(format *LogFormat) ([]byte, error)
}
)
type (
FieldsBody []LogField
ListBody []string
MessageBody string
)
var (
LogFormatMarkdown = &LogFormat{"markdown"}
LogFormatPlain = &LogFormat{"plain"}
LogFormatRawJSON = &LogFormat{"json"} // internal use only
)
func MakeLogFields(fields ...LogField) LogBody {
return FieldsBody(fields)
}
func (f *LogFormat) Parse(format string) error {
switch format {
case "":
f.string = LogFormatMarkdown.string
case LogFormatPlain.string, LogFormatMarkdown.string:
f.string = format
default:
return gperr.Multiline().
Addf("invalid log format %s, supported formats:", format).
AddLines(
LogFormatPlain,
LogFormatMarkdown,
)
}
return nil
}
func (f FieldsBody) Format(format *LogFormat) ([]byte, error) {
switch format {
case LogFormatMarkdown:
var msg bytes.Buffer
for _, field := range f {
msg.WriteString("#### ")
msg.WriteString(field.Name)
msg.WriteRune('\n')
msg.WriteString(field.Value)
msg.WriteRune('\n')
}
return msg.Bytes(), nil
case LogFormatPlain:
var msg bytes.Buffer
for _, field := range f {
msg.WriteString(field.Name)
msg.WriteString(": ")
msg.WriteString(field.Value)
msg.WriteRune('\n')
}
return msg.Bytes(), nil
case LogFormatRawJSON:
return json.Marshal(f)
}
return nil, fmt.Errorf("unknown format: %v", format)
}
func (l ListBody) Format(format *LogFormat) ([]byte, error) {
switch format {
case LogFormatPlain:
return []byte(strings.Join(l, "\n")), nil
case LogFormatMarkdown:
var msg bytes.Buffer
for _, item := range l {
msg.WriteString("* ")
msg.WriteString(item)
msg.WriteRune('\n')
}
return msg.Bytes(), nil
case LogFormatRawJSON:
return json.Marshal(l)
}
return nil, fmt.Errorf("unknown format: %v", format)
}
func (m MessageBody) Format(format *LogFormat) ([]byte, error) {
switch format {
case LogFormatPlain, LogFormatMarkdown:
return []byte(m), nil
case LogFormatRawJSON:
return json.Marshal(m)
}
return nil, fmt.Errorf("unknown format: %v", format)
}

View file

@ -46,11 +46,5 @@ func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err gperr.Error)
Withf("expect %s or %s", ProviderWebhook, ProviderGotify)
}
// unmarshal provider config
if err := utils.MapUnmarshalValidate(m, cfg.Provider); err != nil {
return err
}
// validate provider
return cfg.Provider.Validate()
return utils.MapUnmarshalValidate(m, cfg.Provider)
}

View file

@ -25,8 +25,9 @@ func TestNotificationConfig(t *testing.T) {
},
expected: &Webhook{
ProviderBase: ProviderBase{
Name: "test",
URL: "https://example.com",
Name: "test",
URL: "https://example.com",
Format: LogFormatMarkdown,
},
Template: "discord",
Method: http.MethodPost,
@ -43,12 +44,32 @@ func TestNotificationConfig(t *testing.T) {
"provider": "gotify",
"url": "https://example.com",
"token": "token",
"format": "plain",
},
expected: &GotifyClient{
ProviderBase: ProviderBase{
Name: "test",
URL: "https://example.com",
Token: "token",
Name: "test",
URL: "https://example.com",
Token: "token",
Format: LogFormatPlain,
},
},
wantErr: false,
},
{
name: "default_format",
cfg: map[string]any{
"name": "test",
"provider": "gotify",
"token": "token",
"url": "https://example.com",
},
expected: &GotifyClient{
ProviderBase: ProviderBase{
Name: "test",
URL: "https://example.com",
Token: "token",
Format: LogFormatMarkdown,
},
},
wantErr: false,
@ -62,6 +83,16 @@ func TestNotificationConfig(t *testing.T) {
},
wantErr: true,
},
{
name: "invalid_format",
cfg: map[string]any{
"name": "test",
"provider": "webhook",
"url": "https://example.com",
"format": "invalid",
},
wantErr: true,
},
{
name: "missing_url",
cfg: map[string]any{

View file

@ -14,16 +14,11 @@ type (
logCh chan *LogMessage
providers F.Set[Provider]
}
LogField struct {
Name string `json:"name"`
Value string `json:"value"`
}
LogFields []LogField
LogMessage struct {
Level zerolog.Level
Title string
Extras LogFields
Color Color
Level zerolog.Level
Title string
Body LogBody
Color Color
}
)
@ -53,7 +48,7 @@ func Notify(msg *LogMessage) {
}
}
func (f *LogFields) Add(name, value string) {
func (f *FieldsBody) Add(name, value string) {
*f = append(*f, LogField{Name: name, Value: value})
}

View file

@ -1,26 +0,0 @@
package notif
import (
"bytes"
"encoding/json"
)
func formatMarkdown(extras LogFields) string {
msg := bytes.NewBufferString("")
for _, field := range extras {
msg.WriteString("#### ")
msg.WriteString(field.Name)
msg.WriteRune('\n')
msg.WriteString(field.Value)
msg.WriteRune('\n')
}
return msg.String()
}
func formatDiscord(extras LogFields) (string, error) {
fields, err := json.Marshal(extras)
if err != nil {
return "", err
}
return string(fields), nil
}

View file

@ -1,10 +1,8 @@
package notif
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gotify/server/v2/model"
@ -24,8 +22,8 @@ func (client *GotifyClient) GetURL() string {
return client.URL + gotifyMsgEndpoint
}
// MakeBody implements Provider.
func (client *GotifyClient) MakeBody(logMsg *LogMessage) (io.Reader, error) {
// MarshalMessage implements Provider.
func (client *GotifyClient) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
var priority int
switch logMsg.Level {
@ -37,15 +35,23 @@ func (client *GotifyClient) MakeBody(logMsg *LogMessage) (io.Reader, error) {
priority = 8
}
body, err := logMsg.Body.Format(client.Format)
if err != nil {
return nil, err
}
msg := &GotifyMessage{
Title: logMsg.Title,
Message: formatMarkdown(logMsg.Extras),
Message: string(body),
Priority: &priority,
Extras: map[string]interface{}{
}
if client.Format == LogFormatMarkdown {
msg.Extras = map[string]interface{}{
"client::display": map[string]string{
"contentType": "text/markdown",
},
},
}
}
data, err := json.Marshal(msg)
@ -53,7 +59,7 @@ func (client *GotifyClient) MakeBody(logMsg *LogMessage) (io.Reader, error) {
return nil, err
}
return bytes.NewReader(data), nil
return data, nil
}
// makeRespError implements Provider.

View file

@ -1,10 +1,7 @@
package notif
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/gperr"
@ -13,18 +10,14 @@ import (
// See https://docs.ntfy.sh/publish
type Ntfy struct {
ProviderBase
Topic string `json:"topic"`
Style NtfyStyle `json:"style"`
Topic string `json:"topic"`
}
type NtfyStyle string
const (
NtfyStyleMarkdown NtfyStyle = "markdown"
NtfyStylePlain NtfyStyle = "plain"
)
// Validate implements the utils.CustomValidator interface.
func (n *Ntfy) Validate() gperr.Error {
if err := n.ProviderBase.Validate(); err != nil {
return err
}
if n.URL == "" {
return gperr.New("url is required")
}
@ -34,16 +27,10 @@ func (n *Ntfy) Validate() gperr.Error {
if n.Topic[0] == '/' {
return gperr.New("topic should not start with a slash")
}
switch n.Style {
case "":
n.Style = NtfyStyleMarkdown
case NtfyStyleMarkdown, NtfyStylePlain:
default:
return gperr.Errorf("invalid style, expecting %q or %q, got %q", NtfyStyleMarkdown, NtfyStylePlain, n.Style)
}
return nil
}
// GetURL implements Provider.
func (n *Ntfy) GetURL() string {
if n.URL[len(n.URL)-1] == '/' {
return n.URL + n.Topic
@ -51,23 +38,22 @@ func (n *Ntfy) GetURL() string {
return n.URL + "/" + n.Topic
}
// GetMIMEType implements Provider.
func (n *Ntfy) GetMIMEType() string {
return ""
}
// GetToken implements Provider.
func (n *Ntfy) GetToken() string {
return n.Token
}
func (n *Ntfy) MakeBody(logMsg *LogMessage) (io.Reader, error) {
switch n.Style {
case NtfyStyleMarkdown:
return strings.NewReader(formatMarkdown(logMsg.Extras)), nil
default:
return &bytes.Buffer{}, nil
}
// MarshalMessage implements Provider.
func (n *Ntfy) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
return logMsg.Body.Format(n.Format)
}
// SetHeaders implements Provider.
func (n *Ntfy) SetHeaders(logMsg *LogMessage, headers http.Header) {
headers.Set("Title", logMsg.Title)
@ -83,7 +69,7 @@ func (n *Ntfy) SetHeaders(logMsg *LogMessage, headers http.Header) {
headers.Set("Priority", "min")
}
if n.Style == NtfyStyleMarkdown {
if n.Format == LogFormatMarkdown {
headers.Set("Markdown", "yes")
}
}

View file

@ -1,8 +1,8 @@
package notif
import (
"bytes"
"context"
"io"
"net/http"
"time"
@ -21,7 +21,7 @@ type (
GetMethod() string
GetMIMEType() string
MakeBody(logMsg *LogMessage) (io.Reader, error)
MarshalMessage(logMsg *LogMessage) ([]byte, error)
SetHeaders(logMsg *LogMessage, headers http.Header)
makeRespError(resp *http.Response) error
@ -37,7 +37,7 @@ const (
)
func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) error {
body, err := provider.MakeBody(msg)
body, err := provider.MarshalMessage(msg)
if err != nil {
return gperr.PrependSubject(provider.GetName(), err)
}
@ -49,7 +49,7 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
ctx,
http.MethodPost,
provider.GetURL(),
body,
bytes.NewReader(body),
)
if err != nil {
return gperr.PrependSubject(provider.GetName(), err)

View file

@ -100,12 +100,12 @@ func (webhook *Webhook) makeRespError(resp *http.Response) error {
return fmt.Errorf("%s status %d", webhook.Name, resp.StatusCode)
}
func (webhook *Webhook) MakeBody(logMsg *LogMessage) (io.Reader, error) {
func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
title, err := json.Marshal(logMsg.Title)
if err != nil {
return nil, err
}
fields, err := formatDiscord(logMsg.Extras)
fields, err := logMsg.Body.Format(LogFormatRawJSON)
if err != nil {
return nil, err
}
@ -115,14 +115,14 @@ func (webhook *Webhook) MakeBody(logMsg *LogMessage) (io.Reader, error) {
} else {
color = logMsg.Color.DecString()
}
message, err := json.Marshal(formatMarkdown(logMsg.Extras))
message, err := logMsg.Body.Format(LogFormatMarkdown)
if err != nil {
return nil, err
}
plTempl := strings.NewReplacer(
"$title", string(title),
"$message", string(message),
"$fields", fields,
"$fields", string(fields),
"$color", color,
)
var pl string
@ -132,5 +132,5 @@ func (webhook *Webhook) MakeBody(logMsg *LogMessage) (io.Reader, error) {
pl = webhook.Payload
}
pl = plTempl.Replace(pl)
return strings.NewReader(pl), nil
return []byte(pl), nil
}

View file

@ -222,7 +222,7 @@ func (mon *monitor) checkUpdateHealth() error {
status = health.StatusUnhealthy
}
if result.Healthy != (mon.status.Swap(status) == health.StatusHealthy) {
extras := notif.LogFields{
extras := notif.FieldsBody{
{Name: "Service Name", Value: mon.service},
{Name: "Time", Value: strutils.FormatTime(time.Now())},
}
@ -239,16 +239,16 @@ func (mon *monitor) checkUpdateHealth() error {
logger.Info().Msg("service is up")
extras.Add("Ping", fmt.Sprintf("%d ms", result.Latency.Milliseconds()))
notif.Notify(&notif.LogMessage{
Title: "✅ Service is up ✅",
Extras: extras,
Color: notif.ColorSuccess,
Title: "✅ Service is up ✅",
Body: extras,
Color: notif.ColorSuccess,
})
} else {
logger.Warn().Msg("service went down")
notif.Notify(&notif.LogMessage{
Title: "❌ Service went down ❌",
Extras: extras,
Color: notif.ColorError,
Title: "❌ Service went down ❌",
Body: extras,
Color: notif.ColorError,
})
}
}