Feat/ntfy (#57)

* implement ntfy notification

* fix notification fields order

* fix schema for ntfy

---------

Co-authored-by: yusing <yusing@6uo.me>
This commit is contained in:
Yuzerion 2025-02-01 13:07:44 +08:00 committed by GitHub
parent c16a0444ca
commit 78900772bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 171 additions and 44 deletions

View file

@ -1,6 +1,7 @@
package notif
import (
"io"
"net/http"
"net/url"
"strings"
@ -54,3 +55,15 @@ func (base *ProviderBase) GetMethod() string {
func (base *ProviderBase) GetMIMEType() string {
return "application/json"
}
func (base *ProviderBase) SetHeaders(logMsg *LogMessage, headers http.Header) {
// no-op by default
}
func (base *ProviderBase) makeRespError(resp *http.Response) error {
body, err := io.ReadAll(resp.Body)
if err == nil {
return E.Errorf("%s status %d: %s", base.Name, resp.StatusCode, body)
}
return E.Errorf("%s status %d", base.Name, resp.StatusCode)
}

View file

@ -5,9 +5,9 @@ import "fmt"
type Color uint
const (
Red Color = 0xff0000
Green Color = 0x00ff00
Blue Color = 0x0000ff
ColorError Color = 0xff0000
ColorSuccess Color = 0x00ff00
ColorInfo Color = 0x0000ff
)
func (c Color) HexString() string {

View file

@ -38,6 +38,8 @@ func (cfg *NotificationConfig) UnmarshalMap(m map[string]any) (err E.Error) {
cfg.Provider = &Webhook{}
case ProviderGotify:
cfg.Provider = &GotifyClient{}
case ProviderNtfy:
cfg.Provider = &Ntfy{}
default:
return ErrUnknownNotifProvider.
Subject(cfg.ProviderName).

View file

@ -14,10 +14,15 @@ 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 map[string]any
Extras LogFields
Color Color
}
)
@ -48,6 +53,10 @@ func Notify(msg *LogMessage) {
}
}
func (f *LogFields) Add(name, value string) {
*f = append(*f, LogField{Name: name, Value: value})
}
func (disp *Dispatcher) RegisterProvider(cfg *NotificationConfig) {
disp.providers.Add(cfg.Provider)
}

View file

@ -3,32 +3,22 @@ package notif
import (
"bytes"
"encoding/json"
"fmt"
)
func formatMarkdown(extras map[string]interface{}) string {
func formatMarkdown(extras LogFields) string {
msg := bytes.NewBufferString("")
for k, v := range extras {
for _, field := range extras {
msg.WriteString("#### ")
msg.WriteString(k)
msg.WriteString(field.Name)
msg.WriteRune('\n')
msg.WriteString(fmt.Sprintf("%v", v))
msg.WriteString(field.Value)
msg.WriteRune('\n')
}
return msg.String()
}
func formatDiscord(extras map[string]interface{}) (string, error) {
fieldsMap := make([]map[string]any, len(extras))
i := 0
for k, extra := range extras {
fieldsMap[i] = map[string]any{
"name": k,
"value": extra,
}
i++
}
fields, err := json.Marshal(fieldsMap)
func formatDiscord(extras LogFields) (string, error) {
fields, err := json.Marshal(extras)
if err != nil {
return "", err
}

View file

@ -61,7 +61,7 @@ func (client *GotifyClient) makeRespError(resp *http.Response) error {
var errm model.Error
err := json.NewDecoder(resp.Body).Decode(&errm)
if err != nil {
return fmt.Errorf(ProviderGotify+" status %d, but failed to decode err response: %w", resp.StatusCode, err)
return fmt.Errorf("%s status %d, but failed to decode err response: %w", client.Name, resp.StatusCode, err)
}
return fmt.Errorf(ProviderGotify+" status %d %s: %s", resp.StatusCode, errm.Error, errm.ErrorDescription)
return fmt.Errorf("%s status %d %s: %s", client.Name, resp.StatusCode, errm.Error, errm.ErrorDescription)
}

89
internal/notif/ntfy.go Normal file
View file

@ -0,0 +1,89 @@
package notif
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/rs/zerolog"
E "github.com/yusing/go-proxy/internal/error"
)
// See https://docs.ntfy.sh/publish
type Ntfy struct {
ProviderBase
Topic string `json:"topic"`
Style NtfyStyle `json:"style"`
}
type NtfyStyle string
const (
NtfyStyleMarkdown NtfyStyle = "markdown"
NtfyStylePlain NtfyStyle = "plain"
)
func (n *Ntfy) Validate() E.Error {
if n.URL == "" {
return E.New("url is required")
}
if n.Topic == "" {
return E.New("topic is required")
}
if n.Topic[0] == '/' {
return E.New("topic should not start with a slash")
}
switch n.Style {
case "":
n.Style = NtfyStyleMarkdown
case NtfyStyleMarkdown, NtfyStylePlain:
default:
return E.Errorf("invalid style, expecting %q or %q, got %q", NtfyStyleMarkdown, NtfyStylePlain, n.Style)
}
return nil
}
func (n *Ntfy) GetURL() string {
if n.URL[len(n.URL)-1] == '/' {
return n.URL + n.Topic
}
return n.URL + "/" + n.Topic
}
func (n *Ntfy) GetMIMEType() string {
return ""
}
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
}
}
func (n *Ntfy) SetHeaders(logMsg *LogMessage, headers http.Header) {
headers.Set("Title", logMsg.Title)
switch logMsg.Level {
// warning (or other unspecified) uses default priority
case zerolog.FatalLevel:
headers.Set("Priority", "urgent")
case zerolog.ErrorLevel:
headers.Set("Priority", "high")
case zerolog.InfoLevel:
headers.Set("Priority", "low")
case zerolog.DebugLevel:
headers.Set("Priority", "min")
}
if n.Style == NtfyStyleMarkdown {
headers.Set("Markdown", "yes")
}
}

View file

@ -21,6 +21,7 @@ type (
GetMIMEType() string
MakeBody(logMsg *LogMessage) (io.Reader, error)
SetHeaders(logMsg *LogMessage, headers http.Header)
makeRespError(resp *http.Response) error
}
@ -30,6 +31,7 @@ type (
const (
ProviderGotify = "gotify"
ProviderNtfy = "ntfy"
ProviderWebhook = "webhook"
)
@ -52,6 +54,7 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
if provider.GetToken() != "" {
req.Header.Set("Authorization", "Bearer "+provider.GetToken())
}
provider.SetHeaders(msg, req.Header)
resp, err := http.DefaultClient.Do(req)
if err != nil {

View file

@ -92,12 +92,12 @@ func (webhook *Webhook) GetMIMEType() string {
func (webhook *Webhook) makeRespError(resp *http.Response) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("webhook status %d, failed to read body: %w", resp.StatusCode, err)
return fmt.Errorf("%s status %d, failed to read body: %w", webhook.Name, resp.StatusCode, err)
}
if len(body) > 0 {
return fmt.Errorf("webhook status %d: %s", resp.StatusCode, body)
return fmt.Errorf("%s status %d: %s", webhook.Name, resp.StatusCode, body)
}
return fmt.Errorf("webhook status %d", resp.StatusCode)
return fmt.Errorf("%s status %d", webhook.Name, resp.StatusCode)
}
func (webhook *Webhook) MakeBody(logMsg *LogMessage) (io.Reader, error) {

View file

@ -198,33 +198,33 @@ func (mon *monitor) checkUpdateHealth() error {
status = health.StatusUnhealthy
}
if result.Healthy != (mon.status.Swap(status) == health.StatusHealthy) {
extras := map[string]any{
"Service Name": mon.service,
"Time": strutils.FormatTime(time.Now()),
extras := notif.LogFields{
{Name: "Service Name", Value: mon.service},
{Name: "Time", Value: strutils.FormatTime(time.Now())},
}
if !result.Healthy {
extras["Last Seen"] = strutils.FormatLastSeen(GetLastSeen(mon.service))
extras.Add("Last Seen", strutils.FormatLastSeen(GetLastSeen(mon.service)))
}
if !mon.url.Load().Nil() {
extras["Service URL"] = mon.url.Load().String()
extras.Add("Service URL", mon.url.Load().String())
}
if result.Detail != "" {
extras["Detail"] = result.Detail
extras.Add("Detail", result.Detail)
}
if result.Healthy {
logger.Info().Msg("service is up")
extras["Ping"] = fmt.Sprintf("%d ms", result.Latency.Milliseconds())
extras.Add("Ping", fmt.Sprintf("%d ms", result.Latency.Milliseconds()))
notif.Notify(&notif.LogMessage{
Title: "✅ Service is up ✅",
Extras: extras,
Color: notif.Green,
Color: notif.ColorSuccess,
})
} else {
logger.Warn().Msg("service went down")
notif.Notify(&notif.LogMessage{
Title: "❌ Service went down ❌",
Extras: extras,
Color: notif.Red,
Color: notif.ColorError,
})
}
}

View file

@ -1,6 +1,6 @@
{
"name": "godoxy-schemas",
"version": "0.9.0-22",
"version": "0.9.1-1",
"description": "JSON Schema and typescript types for GoDoxy configuration",
"license": "MIT",
"repository": {

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
import { URL } from "../types";
export declare const NOTIFICATION_PROVIDERS: readonly ["webhook", "gotify"];
export declare const NOTIFICATION_PROVIDERS: readonly ["webhook", "gotify", "ntfy"];
export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number];
export type NotificationConfig = {
name: string;
@ -9,9 +9,17 @@ export interface GotifyConfig extends NotificationConfig {
provider: "gotify";
token: string;
}
export declare const NTFY_MSG_STYLES: string[];
export type NtfyStyle = (typeof NTFY_MSG_STYLES)[number];
export interface NtfyConfig extends NotificationConfig {
provider: "ntfy";
topic: string;
token?: string;
style?: NtfyStyle;
}
export declare const WEBHOOK_TEMPLATES: readonly ["", "discord"];
export declare const WEBHOOK_METHODS: readonly ["POST", "GET", "PUT"];
export declare const WEBHOOK_MIME_TYPES: readonly ["application/json", "application/x-www-form-urlencoded", "text/plain"];
export declare const WEBHOOK_MIME_TYPES: readonly ["application/json", "application/x-www-form-urlencoded", "text/plain", "text/markdown"];
export declare const WEBHOOK_COLOR_MODES: readonly ["hex", "dec"];
export type WebhookTemplate = (typeof WEBHOOK_TEMPLATES)[number];
export type WebhookMethod = (typeof WEBHOOK_METHODS)[number];

View file

@ -1,9 +1,11 @@
export const NOTIFICATION_PROVIDERS = ["webhook", "gotify"];
export const NOTIFICATION_PROVIDERS = ["webhook", "gotify", "ntfy"];
export const NTFY_MSG_STYLES = ["markdown", "plain"];
export const WEBHOOK_TEMPLATES = ["", "discord"];
export const WEBHOOK_METHODS = ["POST", "GET", "PUT"];
export const WEBHOOK_MIME_TYPES = [
"application/json",
"application/x-www-form-urlencoded",
"text/plain",
"text/markdown",
];
export const WEBHOOK_COLOR_MODES = ["hex", "dec"];

View file

@ -1,6 +1,6 @@
import { URL } from "../types";
export const NOTIFICATION_PROVIDERS = ["webhook", "gotify"] as const;
export const NOTIFICATION_PROVIDERS = ["webhook", "gotify", "ntfy"] as const;
export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number];
@ -17,12 +17,23 @@ export interface GotifyConfig extends NotificationConfig {
token: string;
}
export const NTFY_MSG_STYLES = ["markdown", "plain"];
export type NtfyStyle = (typeof NTFY_MSG_STYLES)[number];
export interface NtfyConfig extends NotificationConfig {
provider: "ntfy";
topic: string;
token?: string;
style?: NtfyStyle;
}
export const WEBHOOK_TEMPLATES = ["", "discord"] as const;
export const WEBHOOK_METHODS = ["POST", "GET", "PUT"] as const;
export const WEBHOOK_MIME_TYPES = [
"application/json",
"application/x-www-form-urlencoded",
"text/plain",
"text/markdown",
] as const;
export const WEBHOOK_COLOR_MODES = ["hex", "dec"] as const;

View file

@ -1,5 +1,5 @@
import { URI, URL } from "../types";
import { GotifyConfig, WebhookConfig } from "./notification";
import { GotifyConfig, NtfyConfig, WebhookConfig } from "./notification";
export type Providers = {
/** List of route definition files to include
*
@ -21,7 +21,7 @@ export type Providers = {
* @minItems 1
* @examples require(".").notificationExamples
*/
notification?: (WebhookConfig | GotifyConfig)[];
notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[];
};
export declare const includeExamples: readonly ["file1.yml", "file2.yml"];
export declare const dockerExamples: readonly [{

View file

@ -1,5 +1,5 @@
import { URI, URL } from "../types";
import { GotifyConfig, WebhookConfig } from "./notification";
import { GotifyConfig, NtfyConfig, WebhookConfig } from "./notification";
export type Providers = {
/** List of route definition files to include
@ -20,7 +20,7 @@ export type Providers = {
* @minItems 1
* @examples require(".").notificationExamples
*/
notification?: (WebhookConfig | GotifyConfig)[];
notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[];
};
export const includeExamples = ["file1.yml", "file2.yml"] as const;