From 69ee8495d8b7226fdac8fc439936e8e5f7c378a3 Mon Sep 17 00:00:00 2001 From: yusing Date: Fri, 2 May 2025 05:51:15 +0800 Subject: [PATCH] refactor: notifications --- internal/notif/base.go | 11 +- internal/notif/body.go | 111 +++++++++++++++++++++ internal/notif/config.go | 8 +- internal/notif/config_test.go | 41 +++++++- internal/notif/dispatcher.go | 15 +-- internal/notif/format.go | 26 ----- internal/notif/gotify.go | 22 ++-- internal/notif/ntfy.go | 40 +++----- internal/notif/providers.go | 8 +- internal/notif/webhook.go | 10 +- internal/watcher/health/monitor/monitor.go | 14 +-- 11 files changed, 202 insertions(+), 104 deletions(-) create mode 100644 internal/notif/body.go delete mode 100644 internal/notif/format.go diff --git a/internal/notif/base.go b/internal/notif/base.go index a1db3af..da55939 100644 --- a/internal/notif/base.go +++ b/internal/notif/base.go @@ -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 diff --git a/internal/notif/body.go b/internal/notif/body.go new file mode 100644 index 0000000..cac661f --- /dev/null +++ b/internal/notif/body.go @@ -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) +} diff --git a/internal/notif/config.go b/internal/notif/config.go index 5f48f2e..8bc396e 100644 --- a/internal/notif/config.go +++ b/internal/notif/config.go @@ -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) } diff --git a/internal/notif/config_test.go b/internal/notif/config_test.go index 1ade43e..a7afaac 100644 --- a/internal/notif/config_test.go +++ b/internal/notif/config_test.go @@ -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{ diff --git a/internal/notif/dispatcher.go b/internal/notif/dispatcher.go index b48a06a..9e7cba9 100644 --- a/internal/notif/dispatcher.go +++ b/internal/notif/dispatcher.go @@ -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}) } diff --git a/internal/notif/format.go b/internal/notif/format.go deleted file mode 100644 index f54d897..0000000 --- a/internal/notif/format.go +++ /dev/null @@ -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 -} diff --git a/internal/notif/gotify.go b/internal/notif/gotify.go index bb2d951..c369304 100644 --- a/internal/notif/gotify.go +++ b/internal/notif/gotify.go @@ -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. diff --git a/internal/notif/ntfy.go b/internal/notif/ntfy.go index 6e10725..586fdc5 100644 --- a/internal/notif/ntfy.go +++ b/internal/notif/ntfy.go @@ -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") } } diff --git a/internal/notif/providers.go b/internal/notif/providers.go index 953fb3b..3032fb4 100644 --- a/internal/notif/providers.go +++ b/internal/notif/providers.go @@ -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) diff --git a/internal/notif/webhook.go b/internal/notif/webhook.go index 13fd23c..f2343b8 100644 --- a/internal/notif/webhook.go +++ b/internal/notif/webhook.go @@ -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 } diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index 2eded60..8f4ddf3 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -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(¬if.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(¬if.LogMessage{ - Title: "❌ Service went down ❌", - Extras: extras, - Color: notif.ColorError, + Title: "❌ Service went down ❌", + Body: extras, + Color: notif.ColorError, }) } }