// Package alerts handles alert management and delivery. package alerts import ( "fmt" "net/mail" "net/url" "sync" "time" "github.com/nicholas-fedor/shoutrrr" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/mailer" ) type hubLike interface { core.App MakeLink(parts ...string) string } type AlertManager struct { hub hubLike alertQueue chan alertTask stopChan chan struct{} pendingAlerts sync.Map } type AlertMessageData struct { UserID string Title string Message string Link string LinkText string } type UserNotificationSettings struct { Emails []string `json:"emails"` Webhooks []string `json:"webhooks"` } type SystemAlertStats struct { Cpu float64 `json:"cpu"` Mem float64 `json:"mp"` Disk float64 `json:"dp"` NetSent float64 `json:"ns"` NetRecv float64 `json:"nr"` Temperatures map[string]float32 `json:"t"` LoadAvg [3]float64 `json:"la"` } type SystemAlertData struct { systemRecord *core.Record alertRecord *core.Record name string unit string val float64 threshold float64 triggered bool time time.Time count uint8 min uint8 mapSums map[string]float32 descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc) } // notification services that support title param var supportsTitle = map[string]struct{}{ "bark": {}, "discord": {}, "gotify": {}, "ifttt": {}, "join": {}, "lark": {}, "matrix": {}, "ntfy": {}, "opsgenie": {}, "pushbullet": {}, "pushover": {}, "slack": {}, "teams": {}, "telegram": {}, "zulip": {}, } // NewAlertManager creates a new AlertManager instance. func NewAlertManager(app hubLike) *AlertManager { am := &AlertManager{ hub: app, alertQueue: make(chan alertTask, 5), stopChan: make(chan struct{}), } am.bindEvents() go am.startWorker() return am } // Bind events to the alerts collection lifecycle func (am *AlertManager) bindEvents() { am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate) am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) } // SendAlert sends an alert to the user func (am *AlertManager) SendAlert(data AlertMessageData) error { // get user settings record, err := am.hub.FindFirstRecordByFilter( "user_settings", "user={:user}", dbx.Params{"user": data.UserID}, ) if err != nil { return err } // unmarshal user settings userAlertSettings := UserNotificationSettings{ Emails: []string{}, Webhooks: []string{}, } if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil { am.hub.Logger().Error("Failed to unmarshal user settings", "err", err) } // send alerts via webhooks for _, webhook := range userAlertSettings.Webhooks { if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil { am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err) } } // send alerts via email if len(userAlertSettings.Emails) == 0 { return nil } addresses := []mail.Address{} for _, email := range userAlertSettings.Emails { addresses = append(addresses, mail.Address{Address: email}) } message := mailer.Message{ To: addresses, Subject: data.Title, Text: data.Message + fmt.Sprintf("\n\n%s", data.Link), From: mail.Address{ Address: am.hub.Settings().Meta.SenderAddress, Name: am.hub.Settings().Meta.SenderName, }, } err = am.hub.NewMailClient().Send(&message) if err != nil { return err } am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject) return nil } // SendShoutrrrAlert sends an alert via a Shoutrrr URL func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error { // Parse the URL parsedURL, err := url.Parse(notificationUrl) if err != nil { return fmt.Errorf("error parsing URL: %v", err) } scheme := parsedURL.Scheme queryParams := parsedURL.Query() // Add title if _, ok := supportsTitle[scheme]; ok { queryParams.Add("title", title) } else if scheme == "mattermost" { // use markdown title for mattermost message = "##### " + title + "\n\n" + message } else if scheme == "generic" && queryParams.Has("template") { // add title as property if using generic with template json titleKey := queryParams.Get("titlekey") if titleKey == "" { titleKey = "title" } queryParams.Add("$"+titleKey, title) } else { // otherwise just add title to message message = title + "\n\n" + message } // Add link if scheme == "ntfy" { queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link)) } else if scheme == "lark" { queryParams.Add("link", link) } else if scheme == "bark" { queryParams.Add("url", link) } else { message += "\n\n" + link } // Encode the modified query parameters back into the URL parsedURL.RawQuery = queryParams.Encode() // log.Println("URL after modification:", parsedURL.String()) err = shoutrrr.Send(parsedURL.String(), message) if err == nil { am.hub.Logger().Info("Sent shoutrrr alert", "title", title) } else { am.hub.Logger().Error("Error sending shoutrrr alert", "err", err) return err } return nil } func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error { var data struct { URL string `json:"url"` } err := e.BindBody(&data) if err != nil || data.URL == "" { return e.BadRequestError("URL is required", err) } err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel") if err != nil { return e.JSON(200, map[string]string{"err": err.Error()}) } return e.JSON(200, map[string]bool{"err": false}) }