diff --git a/Dockerfile b/Dockerfile index 603ab48..e55f158 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ FROM scratch WORKDIR /app COPY --from=build /frigate-notify /app/frigate-notify +COPY /templates /app/templates COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ diff --git a/config/config.go b/config/config.go index 0f92b28..824d5cd 100644 --- a/config/config.go +++ b/config/config.go @@ -1,12 +1,15 @@ package config import ( + "encoding/json" "fmt" "log" "os" "path/filepath" "strings" + "github.com/0x2142/frigate-notify/models" + "github.com/0x2142/frigate-notify/util" "github.com/kkyr/fig" ) @@ -17,11 +20,13 @@ type Config struct { } type Frigate struct { - Server string `fig:"server" validate:"required"` - Insecure bool `fig:"ignoressl" default:false` - WebAPI WebAPI `fig:"webapi"` - MQTT MQTT `fig:"mqtt"` - Cameras Cameras `fig:"cameras"` + Server string `fig:"server" validate:"required"` + Insecure bool `fig:"ignoressl" default:false` + PublicURL string `fig:"public_url" default:""` + Headers []map[string]string `fig:"headers"` + WebAPI WebAPI `fig:"webapi"` + MQTT MQTT `fig:"mqtt"` + Cameras Cameras `fig:"cameras"` } type WebAPI struct { @@ -53,6 +58,7 @@ type Alerts struct { SMTP SMTP `fig:"smtp"` Telegram Telegram `fig:"telegram"` Pushover Pushover `fig:"pushover"` + Nfty Nfty `fig:"nfty"` } type General struct { @@ -110,6 +116,13 @@ type Pushover struct { TTL int `fig:"ttl" default:0` } +type Nfty struct { + Enabled bool `fig:"enabled" default:false` + Server string `fig:"server" default:""` + Topic string `fig:"topic" default:""` + Insecure bool `fig:"ignoressl" default:false` +} + type Monitor struct { Enabled bool `fig:"enabled" default:false` URL string `fig:"url" default:""` @@ -137,11 +150,10 @@ func LoadConfig(configFile string) { if err != nil { log.Fatal("Failed to load config file! Error: ", err) } + log.Print("Config file loaded.") // Send config file to validation before completing validateConfig() - - log.Print("Config file loaded.") } // validateConfig checks config file structure & loads info into associated packages @@ -153,6 +165,11 @@ func validateConfig() { configErrors = append(configErrors, "Please configure only one polling method: Frigate Web API or MQTT") } + // Set default web API interval if not specified + if ConfigData.Frigate.WebAPI.Enabled && ConfigData.Frigate.WebAPI.Interval == 0 { + ConfigData.Frigate.WebAPI.Interval = 30 + } + // Warn on test mode being enabled if ConfigData.Frigate.WebAPI.Enabled && ConfigData.Frigate.WebAPI.TestMode { log.Print("~~~~~~~~~~~~~~~~~~~") @@ -168,6 +185,27 @@ func validateConfig() { ConfigData.Frigate.Server = fmt.Sprintf("http://%s", ConfigData.Frigate.Server) } + // Test connectivity to Frigate + log.Print("Checking connection to Frigate server...") + statsAPI := fmt.Sprintf("%s/api/stats", ConfigData.Frigate.Server) + response, err := util.HTTPGet(statsAPI, ConfigData.Frigate.Insecure) + if err != nil { + log.Fatalf("Cannot reach Frigate server at %v, error: %v", ConfigData.Frigate.Server, err) + } + var stats models.FrigateStats + json.Unmarshal([]byte(response), &stats) + log.Printf("Successfully connected to %v", ConfigData.Frigate.Server) + if stats.Service.Version != "" { + log.Printf("Frigate server is running version %v", stats.Service.Version) + } + + // Check Public / External URL if set + if ConfigData.Frigate.PublicURL != "" { + if !strings.Contains(ConfigData.Frigate.PublicURL, "http://") && !strings.Contains(ConfigData.Frigate.PublicURL, "https://") { + configErrors = append(configErrors, "Public URL must include http:// or https://") + } + } + // Check for camera exclusions if len(ConfigData.Frigate.Cameras.Exclude) > 0 { log.Println("Cameras to exclude from alerting:") @@ -301,6 +339,16 @@ func validateConfig() { configErrors = append(configErrors, "Pushover TTL cannot be negative!") } } + if ConfigData.Alerts.Nfty.Enabled { + log.Print("Nfty alerting enabled.") + if ConfigData.Alerts.Nfty.Server == "" { + configErrors = append(configErrors, "No Nfty server specified!") + } + if ConfigData.Alerts.Nfty.Topic == "" { + configErrors = append(configErrors, "No Nfty topic specified!") + } + + } // Validate monitoring config if ConfigData.Monitor.Enabled { diff --git a/docs/changelog.md b/docs/changelog.md index ac46ca5..39f8558 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,16 @@ # Changelog +## [v0.2.8](https://github.com/0x2142/frigate-notify/releases/tag/v0.2.8) - May 15 2024 + +- Add support for notifications via [Nfty](https://frigate-notify.0x2142.com/config/#nfty) +- Add ability to send additional HTTP [headers](https://frigate-notify.0x2142.com/config/#frigate) to Frigate +- Add new `public_url` config item for Frigate + - This will be used in notification links & should be configured if Frigate is accessible via the internet +- Add startup check to verify Frigate API is accessible +- Rework event notifications to be built from templates +- Fix default interval for querying evens via web API +- Fix issue where label score is 0% via web API event query + ## [v0.2.7](https://github.com/0x2142/frigate-notify/releases/tag/v0.2.7) - May 06 2024 - Allow changing default MQTT topic prefix via config diff --git a/docs/config.md b/docs/config.md index f136f9b..9441eb8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -9,14 +9,28 @@ Configuration snippets will be provided throughout this page. Feel free to copy ### Server - **server** (Required) - - IP or hostname of the Frigate NVR + - IP, hostname, or URL of the Frigate NVR + - If IP or hostname specified, app will prepend `http://` + - If Frigate is not behind a reverse proxy, append port number if necessary - **ignoressl** (Optional - Default: `false`) - - Set to `true` to allow self-signed certificates + - Set to `true` to allow self-signed certificates for `server` +- **public_url** (Optional) + - Should be set if Frigate is available via an external, public URL + - This value is used for the links used in notifications + - Format should be full URL (example: `https://nvr.your.public.domain.tld`) +- **headers** (Optional) + - Send additional HTTP headers to Frigate + - Useful for things like authentication + - Header format: `Header: Value` + - Example: `Authorization: Basic abcd1234` ```yaml title="Config File Snippet" frigate: server: nvr.your.domain.tld ignoressl: true + public_url: https://nvr.your.public.domain.tld + headers: + - Authorization: Basic abcd1234 ``` ### WebAPI @@ -27,7 +41,7 @@ frigate: - **enabled** (Optional - Default: `false`) - If set to `true`, Frigate events are collected by polling the web API - **interval** (Optional - Default: `30`) - - How frequently to check the Frigate web API for new events, in seconds + - How frequently to check the Frigate web API for new events, in seconds ```yaml title="Config File Snippet" frigate: @@ -324,6 +338,28 @@ alerts: ttl: ``` +### Nfty + +- **enabled** (Optional - Default: `false`) + - Set to `true` to enable alerting via Nfty +- **server** (Required) + - Full URL of the desired Nfty server + - Required if this alerting method is enabled +- **topic** (Required) + - Destination topic that will receive alert notifications + - Required if this alerting method is enabled +- **ignoressl** (Optional - Default: `false`) + - Set to `true` to allow self-signed certificates + +```yaml title="Config File Snippet" +alerts: + nfty: + enabled: true + server: https://nfty.your.domain.tld + topic: frigate + ignoressl: true +``` + ## Monitor If enabled, this application will check in with tools like [HealthChecks](https://github.com/healthchecks/healthchecks) or [Uptime Kuma](https://github.com/louislam/uptime-kuma) on a regular interval for health / status monitoring. @@ -347,10 +383,8 @@ monitor: ignoressl: ``` - --- - ## Sample Config { data-search-exclude } A full config file template has been provided below: @@ -358,7 +392,9 @@ A full config file template has been provided below: ```yaml frigate: server: - ignoressl: + ignoressl: + public_url: + headers: webapi: enabled: @@ -431,9 +467,15 @@ alerts: expire: ttl: + nfty: + enabled: false + server: + topic: + ignoressl: + monitor: enabled: false url: interval: ignoressl: -``` \ No newline at end of file +``` diff --git a/events/api.go b/events/api.go index 2475066..41be195 100644 --- a/events/api.go +++ b/events/api.go @@ -9,6 +9,7 @@ import ( "time" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" "github.com/0x2142/frigate-notify/notifier" "github.com/0x2142/frigate-notify/util" "golang.org/x/exp/slices" @@ -35,12 +36,13 @@ func CheckForEvents() { log.Println("Checking for new events...") // Query events - response, err := util.HTTPGet(url, config.ConfigData.Frigate.Insecure) + response, err := util.HTTPGet(url, config.ConfigData.Frigate.Insecure, config.ConfigData.Frigate.Headers...) if err != nil { log.Printf("Cannot get events from %s", url) + log.Printf("Error received: %s", err) } - var events []Event + var events []models.Event json.Unmarshal([]byte(response), &events) @@ -77,17 +79,15 @@ func CheckForEvents() { snapshot = GetSnapshot(snapshotURL, event.ID) } - message := buildMessage(eventTime, event) - // Send alert with snapshot - notifier.SendAlert(message, snapshotURL, snapshot, event.ID) + notifier.SendAlert(event, snapshotURL, snapshot, event.ID) } } // GetSnapshot downloads a snapshot from Frigate func GetSnapshot(snapshotURL, eventID string) io.Reader { - response, err := util.HTTPGet(snapshotURL, config.ConfigData.Frigate.Insecure) + response, err := util.HTTPGet(snapshotURL, config.ConfigData.Frigate.Insecure, config.ConfigData.Frigate.Headers...) if err != nil { log.Println("Could not access snaphot. Error: ", err) } diff --git a/events/events.go b/events/events.go index fdd0e83..d4dd276 100644 --- a/events/events.go +++ b/events/events.go @@ -1,70 +1,13 @@ package frigate import ( - "fmt" "log" "slices" "strings" - "time" "github.com/0x2142/frigate-notify/config" ) -// Event stores Frigate alert attributes -type Event struct { - Area interface{} `json:"area"` - Box interface{} `json:"box"` - Camera string `json:"camera"` - EndTime interface{} `json:"end_time"` - FalsePositive interface{} `json:"false_positive"` - HasClip bool `json:"has_clip"` - HasSnapshot bool `json:"has_snapshot"` - ID string `json:"id"` - Label string `json:"label"` - PlusID interface{} `json:"plus_id"` - Ratio interface{} `json:"ratio"` - Region interface{} `json:"region"` - RetainIndefinitely bool `json:"retain_indefinitely"` - StartTime float64 `json:"start_time"` - SubLabel interface{} `json:"sub_label"` - Thumbnail string `json:"thumbnail"` - TopScore float64 `json:"top_score"` - Zones []string `json:"zones"` - CurrentZones []string `json:"current_zones"` - EnteredZones []string `json:"entered_zones"` -} - -// buildMessage constructs message payload for all alerting methods -func buildMessage(time time.Time, event Event) string { - // If certain time format is provided, re-format date / time string - timestr := time.String() - if config.ConfigData.Alerts.General.TimeFormat != "" { - timestr = time.Format(config.ConfigData.Alerts.General.TimeFormat) - } - // Build alert message payload, include two spaces at end to force markdown newline - message := fmt.Sprintf("Detection at %v ", timestr) - message += fmt.Sprintf("\nCamera: %s ", event.Camera) - // Attach detection label & caculate score percentage - message += fmt.Sprintf("\nLabel: %v (%v%%) ", event.Label, int((event.TopScore * 100))) - // If zones configured / detected, include details - var zones []string - zones = append(zones, event.Zones...) - zones = append(zones, event.CurrentZones...) - if len(zones) >= 1 { - message += fmt.Sprintf("\nZone(s): %v ", strings.Join(zones, ", ")) - } - // Append link to camera - message += "\n\nLinks: " - message += fmt.Sprintf("[Camera](%s/cameras/%s)", config.ConfigData.Frigate.Server, event.Camera) - // If event has a recorded clip, include a link to that as well - if event.HasClip { - message += " | " - message += fmt.Sprintf("[Event Clip](%s/api/events/%s/clip.mp4) ", config.ConfigData.Frigate.Server, event.ID) - } - - return message -} - // isAllowedZone verifies whether a zone should be allowed to generate a notification func isAllowedZone(id string, zones []string) bool { // By default, send events without a zone unless specified otherwise diff --git a/events/mqtt.go b/events/mqtt.go index 73dcfc1..8e0d7ca 100644 --- a/events/mqtt.go +++ b/events/mqtt.go @@ -8,22 +8,12 @@ import ( "time" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" "github.com/0x2142/frigate-notify/notifier" mqtt "github.com/eclipse/paho.mqtt.golang" "golang.org/x/exp/slices" ) -// MQTTEvent stores incoming MQTT payloads from Frigate -type MQTTEvent struct { - Before struct { - Event - } `json:"before,omitempty"` - After struct { - Event - } `json:"after,omitempty"` - Type string `json:"type"` -} - // SubscribeMQTT establishes subscription to MQTT server & listens for messages func SubscribeMQTT() { // MQTT client configuration @@ -61,7 +51,7 @@ func SubscribeMQTT() { // processEvent handles incoming MQTT messages & pulls out relevant info for alerting func processEvent(client mqtt.Client, msg mqtt.Message) { // Parse incoming MQTT message - var event MQTTEvent + var event models.MQTTEvent json.Unmarshal(msg.Payload(), &event) if event.Type == "new" || event.Type == "update" { @@ -109,10 +99,8 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { snapshot = GetSnapshot(snapshotURL, event.After.ID) } - message := buildMessage(eventTime, event.After.Event) - // Send alert with snapshot - notifier.SendAlert(message, snapshotURL, snapshot, event.After.ID) + notifier.SendAlert(event.After.Event, snapshotURL, snapshot, event.After.ID) } } diff --git a/example-config.yml b/example-config.yml index 1624322..d346f89 100644 --- a/example-config.yml +++ b/example-config.yml @@ -9,6 +9,13 @@ frigate: server: # Set to true if using SSL & a self-signed certificate ignoressl: false + # Public / internet-facing Frigate URL, if different from above server address + public_url: + + # List of HTTP headers to send to Frigate, in format Header: Value + headers: + # Example: + # - Authorization: Basic abcd1234 webapi: # Set to true to enable event collection via the web API @@ -127,6 +134,16 @@ alerts: # Optional message lifetime ttl: + # Nfty Config + nfty: + # Set to true to enable alerting via + enabled: false + # URL of Nfty server + server: + # Nfty topic for notifications + topic: + # Set to true if using SSL & a self-signed certificate + ignoressl: ## App Monitoring # Sends HTTP GET to provided URL for aliveness checks diff --git a/go.mod b/go.mod index 4b12184..bee8686 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/disgoorg/disgo v0.17.2 github.com/eclipse/paho.mqtt.golang v1.4.3 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 - github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/gregdel/pushover v1.3.0 github.com/kkyr/fig v0.4.0 github.com/wneessen/go-mail v0.4.1 diff --git a/go.sum b/go.sum index 2eb5963..46826c8 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQ github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= -github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= -github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gregdel/pushover v1.3.0 h1:CewbxqsThoN/1imgwkDKFkRkltaQMoyBV0K9IquQLtw= diff --git a/main.go b/main.go index 7abc1f9..dce78f1 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "github.com/0x2142/frigate-notify/util" ) -var APP_VER = "v0.2.7" +var APP_VER = "v0.2.8" func main() { log.Println("Frigate Notify -", APP_VER) diff --git a/models/event.go b/models/event.go new file mode 100644 index 0000000..e79315c --- /dev/null +++ b/models/event.go @@ -0,0 +1,54 @@ +package models + +// MQTTEvent stores incoming MQTT payloads from Frigate +type MQTTEvent struct { + Before struct { + Event + } `json:"before,omitempty"` + After struct { + Event + } `json:"after,omitempty"` + Type string `json:"type"` +} + +// Event stores Frigate alert attributes +type Event struct { + Area interface{} `json:"area"` + Box interface{} `json:"box"` + Camera string `json:"camera"` + Data struct { + Attributes []interface{} `json:"attributes"` + Box []float64 `json:"box"` + Region []float64 `json:"region"` + Score float64 `json:"score"` + TopScore float64 `json:"top_score"` + Type string `json:"type"` + } `json:"data"` + EndTime interface{} `json:"end_time"` + FalsePositive interface{} `json:"false_positive"` + HasClip bool `json:"has_clip"` + HasSnapshot bool `json:"has_snapshot"` + ID string `json:"id"` + Label string `json:"label"` + PlusID interface{} `json:"plus_id"` + Ratio interface{} `json:"ratio"` + Region interface{} `json:"region"` + RetainIndefinitely bool `json:"retain_indefinitely"` + StartTime float64 `json:"start_time"` + SubLabel interface{} `json:"sub_label"` + Thumbnail string `json:"thumbnail"` + TopScore float64 `json:"top_score"` + Zones []string `json:"zones"` + CurrentZones []string `json:"current_zones"` + EnteredZones []string `json:"entered_zones"` + Extra ExtraFields +} + +// Additional custom fields +type ExtraFields struct { + FormattedTime string + TopScorePercent string + ZoneList string + LocalURL string + PublicURL string +} diff --git a/models/stats.go b/models/stats.go new file mode 100644 index 0000000..a87aad1 --- /dev/null +++ b/models/stats.go @@ -0,0 +1,10 @@ +package models + +type FrigateStats struct { + Service struct { + LastUpdated int `json:"last_updated"` + LatestVersion string `json:"latest_version"` + Uptime int `json:"uptime"` + Version string `json:"version"` + } `json:"service"` +} diff --git a/notifier/alerts.go b/notifier/alerts.go index e73d8cb..834e103 100644 --- a/notifier/alerts.go +++ b/notifier/alerts.go @@ -2,31 +2,83 @@ package notifier import ( "bytes" + "fmt" "io" + "strings" + "text/template" + "time" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" ) // SendAlert forwards alert information to all enabled alerting methods -func SendAlert(message, snapshotURL string, snapshot io.Reader, eventid string) { +func SendAlert(event models.Event, snapshotURL string, snapshot io.Reader, eventid string) { // Create copy of snapshot for each alerting method var snap []byte if snapshot != nil { snap, _ = io.ReadAll(snapshot) } if config.ConfigData.Alerts.Discord.Enabled { - SendDiscordMessage(message, bytes.NewReader(snap), eventid) + SendDiscordMessage(event, bytes.NewReader(snap), eventid) } if config.ConfigData.Alerts.Gotify.Enabled { - SendGotifyPush(message, snapshotURL, eventid) + SendGotifyPush(event, snapshotURL, eventid) } if config.ConfigData.Alerts.SMTP.Enabled { - SendSMTP(message, bytes.NewReader(snap), eventid) + SendSMTP(event, bytes.NewReader(snap), eventid) } if config.ConfigData.Alerts.Telegram.Enabled { - SendTelegramMessage(message, bytes.NewReader(snap), eventid) + SendTelegramMessage(event, bytes.NewReader(snap), eventid) } if config.ConfigData.Alerts.Pushover.Enabled { - SendPushoverMessage(message, bytes.NewReader(snap), eventid) + SendPushoverMessage(event, bytes.NewReader(snap), eventid) } + if config.ConfigData.Alerts.Nfty.Enabled { + SendNftyPush(event, bytes.NewReader(snap), eventid) + } +} + +// Build notification based on template +func renderMessage(sourceTemplate string, event models.Event) string { + // Assign Frigate URL to extra event fields + event.Extra.LocalURL = config.ConfigData.Frigate.Server + event.Extra.PublicURL = config.ConfigData.Frigate.PublicURL + + // MQTT uses CurrentZones, Web API uses Zones + // Combine into one object to use regardless of connection method + event.Zones = append(event.Zones, event.CurrentZones...) + // Join zones into plain comma-separated string + event.Extra.ZoneList = strings.Join(event.Zones, ", ") + + // If certain time format is provided, re-format date / time string + eventTime := time.Unix(int64(event.StartTime), 0) + event.Extra.FormattedTime = eventTime.String() + if config.ConfigData.Alerts.General.TimeFormat != "" { + event.Extra.FormattedTime = eventTime.Format(config.ConfigData.Alerts.General.TimeFormat) + } + + // For Web API query, top-level top_score value is no longer used + // So need to replace it with data.top_score value + if event.TopScore == 0 { + event.TopScore = event.Data.TopScore + } + // Calc TopScore percentage + event.Extra.TopScorePercent = fmt.Sprintf("%v%%", int((event.TopScore * 100))) + + // Render template + var tmpl *template.Template + if sourceTemplate == "markdown" || sourceTemplate == "plaintext" || sourceTemplate == "html" { + var templateFile = "./templates/" + sourceTemplate + ".template" + tmpl = template.Must(template.ParseFiles(templateFile)) + } + + var renderedTemplate bytes.Buffer + err := tmpl.Execute(&renderedTemplate, event) + if err != nil { + panic(err) + } + + return renderedTemplate.String() + } diff --git a/notifier/discord.go b/notifier/discord.go index ae2dde3..1c00413 100644 --- a/notifier/discord.go +++ b/notifier/discord.go @@ -7,14 +7,18 @@ import ( "log" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/webhook" ) // SendDiscordMessage pushes alert message to Discord via webhook -func SendDiscordMessage(message string, snapshot io.Reader, eventid string) { +func SendDiscordMessage(event models.Event, snapshot io.Reader, eventid string) { var err error + // Build notification + message := renderMessage("markdown", event) + // Connect to Discord client, err := webhook.NewWithURL(config.ConfigData.Alerts.Discord.Webhook) if err != nil { diff --git a/notifier/gotify.go b/notifier/gotify.go index 9534c82..ab078f2 100644 --- a/notifier/gotify.go +++ b/notifier/gotify.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" "github.com/0x2142/frigate-notify/util" ) @@ -33,7 +34,10 @@ type gotifyPayload struct { } // SendGotifyPush forwards alert messages to Gotify push notification server -func SendGotifyPush(message, snapshotURL string, eventid string) { +func SendGotifyPush(event models.Event, snapshotURL string, eventid string) { + // Build notification + message := renderMessage("markdown", event) + if snapshotURL != "" { message += fmt.Sprintf("\n\n![](%s)", snapshotURL) } else { @@ -49,13 +53,14 @@ func SendGotifyPush(message, snapshotURL string, eventid string) { data, err := json.Marshal(payload) if err != nil { - log.Println("Event ID %v - Unable to build Gotify payload: ", eventid, err) + log.Printf("Event ID %v - Unable to build Gotify payload: %v", eventid, err) return } gotifyURL := fmt.Sprintf("%s/message?token=%s&", config.ConfigData.Alerts.Gotify.Server, config.ConfigData.Alerts.Gotify.Token) - response, err := util.HTTPPost(gotifyURL, config.ConfigData.Alerts.Gotify.Insecure, data) + header := map[string]string{"Content-Type": "application/json"} + response, err := util.HTTPPost(gotifyURL, config.ConfigData.Alerts.Gotify.Insecure, data, header) if err != nil { log.Print("Failed to send Gotify notification: ", err) return diff --git a/notifier/nfty.go b/notifier/nfty.go new file mode 100644 index 0000000..d64c329 --- /dev/null +++ b/notifier/nfty.go @@ -0,0 +1,49 @@ +package notifier + +import ( + "fmt" + "io" + "log" + "strings" + + "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" + "github.com/0x2142/frigate-notify/util" +) + +// SendNftyPush forwards alert messages to Nfty server +func SendNftyPush(event models.Event, snapshot io.Reader, eventid string) { + // Build notification + message := renderMessage("plaintext", event) + + NftyURL := fmt.Sprintf("%s/%s", config.ConfigData.Alerts.Nfty.Server, config.ConfigData.Alerts.Nfty.Topic) + + // Set headers + var headers []map[string]string + headers = append(headers, map[string]string{"Content-Type": "text/markdown"}) + headers = append(headers, map[string]string{"X-Title": config.ConfigData.Alerts.General.Title}) + + // Set action link to the recorded clip + clip := fmt.Sprintf("%s/api/events/%s/clip.mp4", config.ConfigData.Frigate.Server, eventid) + headers = append(headers, map[string]string{"X-Actions": "view, View Clip, " + clip + ", clear=true"}) + + var attachment []byte + if snapshot != nil { + headers = append(headers, map[string]string{"X-Filename": "snapshot.jpg"}) + attachment, _ = io.ReadAll(snapshot) + } else { + message += "\n\nNo snapshot saved." + } + + // Escape newlines in message + message = strings.ReplaceAll(message, "\n", "\\n") + headers = append(headers, map[string]string{"X-Message": message}) + + _, err := util.HTTPPost(NftyURL, config.ConfigData.Alerts.Nfty.Insecure, attachment, headers...) + if err != nil { + log.Print("Failed to send Nfty notification: ", err) + return + } + + log.Printf("Event ID %v - Nfty alert sent", eventid) +} diff --git a/notifier/pushover.go b/notifier/pushover.go index bd367d8..21b98e9 100644 --- a/notifier/pushover.go +++ b/notifier/pushover.go @@ -8,22 +8,22 @@ import ( "time" "github.com/0x2142/frigate-notify/config" - "github.com/gomarkdown/markdown" + "github.com/0x2142/frigate-notify/models" "github.com/gregdel/pushover" ) // SendPushoverMessage sends alert message through Pushover service -func SendPushoverMessage(message string, snapshot io.Reader, eventid string) { +func SendPushoverMessage(event models.Event, snapshot io.Reader, eventid string) { + // Build notification + message := renderMessage("html", event) + message = strings.ReplaceAll(message, "
", "") + push := pushover.New(config.ConfigData.Alerts.Pushover.Token) recipient := pushover.NewRecipient(config.ConfigData.Alerts.Pushover.Userkey) - // Convert message to HTML & strip newline characters - htmlMessage := string(markdown.ToHTML([]byte(message), nil, nil)) - htmlMessage = strings.Replace(htmlMessage, "\n", "", -1) - // Create new message notif := &pushover.Message{ - Message: htmlMessage, + Message: message, Title: config.ConfigData.Alerts.General.Title, Priority: config.ConfigData.Alerts.Pushover.Priority, HTML: true, @@ -47,12 +47,12 @@ func SendPushoverMessage(message string, snapshot io.Reader, eventid string) { if snapshot != nil { notif.AddAttachment(snapshot) if _, err := push.SendMessage(notif, recipient); err != nil { - log.Print("Event ID %v - Error sending Pushover notification:", eventid, err) + log.Printf("Event ID %v - Error sending Pushover notification: %v", eventid, err) return } } else { if _, err := push.SendMessage(notif, recipient); err != nil { - log.Print("Event ID %v - Error sending Pushover notification:", eventid, err) + log.Printf("Event ID %v - Error sending Pushover notification: %v", eventid, err) return } } diff --git a/notifier/smtp.go b/notifier/smtp.go index 70ecb9e..42db235 100644 --- a/notifier/smtp.go +++ b/notifier/smtp.go @@ -7,12 +7,15 @@ import ( "time" "github.com/0x2142/frigate-notify/config" - "github.com/gomarkdown/markdown" + "github.com/0x2142/frigate-notify/models" "github.com/wneessen/go-mail" ) // SendSMTP forwards alert data via email -func SendSMTP(message string, snapshot io.Reader, eventid string) { +func SendSMTP(event models.Event, snapshot io.Reader, eventid string) { + // Build notification + message := renderMessage("html", event) + // Set up email alert m := mail.NewMsg() m.From(config.ConfigData.Alerts.SMTP.User) @@ -24,9 +27,9 @@ func SendSMTP(message string, snapshot io.Reader, eventid string) { } else { message += "\n\nNo snapshot saved." } + // Convert message body to HTML - htmlMessage := markdown.ToHTML([]byte(message), nil, nil) - m.SetBodyString(mail.TypeTextHTML, string(htmlMessage)) + m.SetBodyString(mail.TypeTextHTML, message) time.Sleep(5 * time.Second) @@ -44,12 +47,12 @@ func SendSMTP(message string, snapshot io.Reader, eventid string) { } if err != nil { - log.Print("Event ID %v - Failed to connect to SMTP Server: ", eventid, err) + log.Printf("Event ID %v - Failed to connect to SMTP Server: %v", eventid, err) } // Send message if err := c.DialAndSend(m); err != nil { - log.Print("Event ID %v - Failed to send SMTP message: ", eventid, err) + log.Printf("Event ID %v - Failed to send SMTP message: %v", eventid, err) return } log.Printf("Event ID %v - SMTP alert sent", eventid) diff --git a/notifier/telegram.go b/notifier/telegram.go index d836d4e..8e7dc27 100644 --- a/notifier/telegram.go +++ b/notifier/telegram.go @@ -6,40 +6,38 @@ import ( "strings" "github.com/0x2142/frigate-notify/config" + "github.com/0x2142/frigate-notify/models" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/gomarkdown/markdown" ) // SendTelegramMessage sends alert through Telegram to individual users -func SendTelegramMessage(message string, snapshot io.Reader, eventid string) { +func SendTelegramMessage(event models.Event, snapshot io.Reader, eventid string) { + // Build notification + message := renderMessage("html", event) + message = strings.ReplaceAll(message, "
", "") + bot, err := tgbotapi.NewBotAPI(config.ConfigData.Alerts.Telegram.Token) if err != nil { - log.Print("Event ID %v - Failed to connect to Telegram:", eventid, err) + log.Printf("Event ID %v - Failed to connect to Telegram: %v", eventid, err) return } - // Convert message to HTML & remove tags not permitted by Telegram - htmlMessage := string(markdown.ToHTML([]byte(message), nil, nil)) - htmlMessage = strings.Replace(htmlMessage, "

", "", -1) - htmlMessage = strings.Replace(htmlMessage, "

", "", -1) - htmlMessage = strings.Replace(htmlMessage, "
", "", -1) - if snapshot != nil { // Attach & send snapshot photo := tgbotapi.NewPhoto(config.ConfigData.Alerts.Telegram.ChatID, tgbotapi.FileReader{Name: "Snapshot", Reader: snapshot}) - photo.Caption = htmlMessage + photo.Caption = message photo.ParseMode = "HTML" if _, err := bot.Send(photo); err != nil { - log.Print("Event ID %v - Failed to send alert via Telegram:", eventid, err) + log.Printf("Event ID %v - Failed to send alert via Telegram: %v", eventid, err) return } } else { // Send plain text message if no snapshot available - htmlMessage += "No snapshot saved." - msg := tgbotapi.NewMessage(config.ConfigData.Alerts.Telegram.ChatID, htmlMessage) + message += "No snapshot saved." + msg := tgbotapi.NewMessage(config.ConfigData.Alerts.Telegram.ChatID, message) msg.ParseMode = "HTML" if _, err := bot.Send(msg); err != nil { - log.Print("Event ID %v - Failed to send alert via Telegram:", eventid, err) + log.Printf("Event ID %v - Failed to send alert via Telegram: %v", eventid, err) return } } diff --git a/templates/html.template b/templates/html.template new file mode 100644 index 0000000..f6123ef --- /dev/null +++ b/templates/html.template @@ -0,0 +1,9 @@ +Detection at {{ .Extra.FormattedTime }}
+Camera: {{ .Camera }}
+Label: {{ .Label }} ({{ .Extra.TopScorePercent }})
+{{ if ge (len .Zones ) 1 }}Zone(s): {{ .Extra.ZoneList }}
+{{ end }} +
+{{ if ne .Extra.PublicURL "" }}Links: Camera {{ if .HasClip }} | Event Clip
{{ end }} +{{ else }}Links: Camera{{ if .HasClip }} | Event Clip
{{ end }} +{{ end }} diff --git a/templates/markdown.template b/templates/markdown.template new file mode 100644 index 0000000..9bb1310 --- /dev/null +++ b/templates/markdown.template @@ -0,0 +1,8 @@ +Detection at {{ .Extra.FormattedTime }} +Camera: {{ .Camera }} +Label: {{ .Label }} ({{ .Extra.TopScorePercent }}) +{{ if ge (len .Zones ) 1 }}Zone(s): {{ .Extra.ZoneList }} +{{ end }} +{{ if ne .Extra.PublicURL "" }}Links: [Camera]({{ .Extra.PublicURL }}/cameras/{{ .Camera }}) {{ if .HasClip }} | [Event Clip]({{ .Extra.PublicURL }}/api/events/{{ .ID }}/clip.mp4){{ end }} +{{ else }}Links: [Camera]({{ .Extra.LocalURL }}/cameras/{{ .Camera }}){{ if .HasClip }} | [Event Clip]({{ .Extra.LocalURL }}/api/events/{{ .ID }}/clip.mp4){{ end }} +{{ end }} diff --git a/templates/plaintext.template b/templates/plaintext.template new file mode 100644 index 0000000..06beeca --- /dev/null +++ b/templates/plaintext.template @@ -0,0 +1,11 @@ +Detection at {{ .Extra.FormattedTime }} +Camera: {{ .Camera }} +Label: {{ .Label }} ({{ .Extra.TopScorePercent }}) +{{ if ge (len .Zones ) 1 }}Zone(s): {{ .Extra.ZoneList }} +{{ end }} +Links: +{{ if ne .Extra.PublicURL "" }} - Camera: {{ .Extra.PublicURL }}/cameras/{{ .Camera }} +{{ if .HasClip }} - Event Clip: {{ .Extra.PublicURL }}/api/events/{{ .ID }}/clip.mp4{{ end }} +{{ else }} - Camera: {{ .Extra.LocalURL }}/cameras/{{ .Camera }} +{{ if .HasClip }} - Event Clip: {{ .Extra.LocalURL }}/api/events/{{ .ID }}/clip.mp4{{ end }} +{{ end }} diff --git a/util/httpclient.go b/util/httpclient.go index 97df943..f8d2f3b 100644 --- a/util/httpclient.go +++ b/util/httpclient.go @@ -3,13 +3,15 @@ package util import ( "bytes" "crypto/tls" + "errors" "io" "net/http" + "strconv" "time" ) // HTTPGet is a simple HTTP client function to return page body -func HTTPGet(url string, insecure bool) ([]byte, error) { +func HTTPGet(url string, insecure bool, headers ...map[string]string) ([]byte, error) { // New HTTP Client client := http.Client{Timeout: 10 * time.Second} @@ -25,6 +27,16 @@ func HTTPGet(url string, insecure bool) ([]byte, error) { return nil, err } + // Add headers + if len(headers) > 0 { + for _, h := range headers { + for k, v := range h { + req.Header.Add(k, v) + } + + } + } + // Send HTTP GET response, err := client.Do(req) if err != nil { @@ -38,12 +50,16 @@ func HTTPGet(url string, insecure bool) ([]byte, error) { return nil, err } + if response.StatusCode != 200 { + return nil, errors.New(strconv.Itoa(response.StatusCode)) + } + return body, nil } // HTTPPost performs an HTTP POST to the target URL // and includes auth parameters, ignoring certificates, etc -func HTTPPost(url string, insecure bool, payload []byte) ([]byte, error) { +func HTTPPost(url string, insecure bool, payload []byte, headers ...map[string]string) ([]byte, error) { // New HTTP Client client := http.Client{Timeout: 10 * time.Second} @@ -58,7 +74,16 @@ func HTTPPost(url string, insecure bool, payload []byte) ([]byte, error) { if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") + + // Add headers + if len(headers) > 0 { + for _, h := range headers { + for k, v := range h { + req.Header.Add(k, v) + } + + } + } // Send HTTP POST response, err := client.Do(req)