diff --git a/LICENSE b/LICENSE index 2532eaf..72848c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Matt Schmitz +Copyright (c) 2023-2024 Matt Schmitz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index aee1cb4..fa8657f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-[![Static Badge](https://img.shields.io/badge/Project-Documentation-blue)](https://frigate-notify.0x2142.com) [![GitHub Repo stars](https://img.shields.io/github/stars/0x2142/frigate-notify)]() [![GitHub release (with filter)](https://img.shields.io/github/v/release/0x2142/frigate-notify)](https://github.com/0x2142/frigate-notify/releases) [![Static Badge](https://img.shields.io/badge/Docker-latest-blue)](https://github.com/0x2142/frigate-notify/pkgs/container/frigate-notify) +[![Static Badge](https://img.shields.io/badge/Documentation-blue)](https://frigate-notify.0x2142.com)    [![GitHub Repo stars](https://img.shields.io/github/stars/0x2142/frigate-notify)]()    [![GitHub release (with filter)](https://img.shields.io/github/v/release/0x2142/frigate-notify)](https://github.com/0x2142/frigate-notify/releases)    [![Static Badge](https://img.shields.io/badge/Docker-latest-blue)](https://github.com/0x2142/frigate-notify/pkgs/container/frigate-notify)    [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-blue?style=flat&logo=buy-me-a-coffee&logoColor=white)](https://www.buymeacoffee.com/0x2142)
@@ -14,11 +14,13 @@ Currently Frigate only supports notifications through Home Assistant, which I'm ## Features -**Event Polling** +### Event Polling + - MQTT - Direct via Frigate API -**Notification Methods** +### Notification Methods + - Discord - Gotify - SMTP @@ -27,9 +29,9 @@ Currently Frigate only supports notifications through Home Assistant, which I'm - Ntfy - Generic Webhook -**Other** -- Aliveness monitor via HTTP GET (for use with tools like [HealthChecks](https://github.com/healthchecks/healthchecks) or [Uptime Kuma](https://github.com/louislam/uptime-kuma)) +### Other +- Aliveness monitor via HTTP GET (for use with tools like [HealthChecks](https://github.com/healthchecks/healthchecks) or [Uptime Kuma](https://github.com/louislam/uptime-kuma)) ## Installation @@ -43,21 +45,7 @@ The sample config contains inline descriptions for each field. For additional de ## Screenshots -**Discord** - -![Discord](/screenshots/discord.png) - -**Gotify** - -![Gotify](/screenshots/gotify.png) - -**SMTP** - -![SMTP](/screenshots/smtp.png) - -**Telegram** - -![Telegram](/screenshots/telegram.png) +For example screenshots of app notifications, see [here](https://github.com/0x2142/frigate-notify/tree/main/screenshots) ## Changelog diff --git a/config/config.go b/config/config.go index e18139d..55222c6 100644 --- a/config/config.go +++ b/config/config.go @@ -18,9 +18,9 @@ import ( ) type Config struct { - Frigate Frigate `fig:"frigate"` - Alerts Alerts `fig:"alerts"` - Monitor Monitor `fig:"monitor"` + Frigate *Frigate `fig:"frigate" validate:"required"` + Alerts *Alerts `fig:"alerts" validate:"required"` + Monitor Monitor `fig:"monitor"` } type Frigate struct { @@ -32,7 +32,7 @@ type Frigate struct { WebAPI WebAPI `fig:"webapi"` MQTT MQTT `fig:"mqtt"` Cameras Cameras `fig:"cameras"` - Version int + Version int // Internal use only } type StartupCheck struct { @@ -83,6 +83,7 @@ type General struct { SnapBbox bool `fig:"snap_bbox" default:false` SnapTimestamp bool `fig:"snap_timestamp" default:false` SnapCrop bool `fig:"snap_crop" default:false` + NotifyOnce bool `fig:"notify_once" default:false` } type Quiet struct { @@ -123,8 +124,10 @@ type SMTP struct { TLS bool `fig:"tls" default:false` User string `fig:"user" default:""` Password string `fig:"password" default:""` + From string `fig:"from" default:""` Recipient string `fig:"recipient" default:""` Template string `fig:"template" default:""` + Insecure bool `fig:"ignoressl" default:false` } type Telegram struct { @@ -169,6 +172,8 @@ type Webhook struct { Enabled bool `fig:"enabled" default:false` Server string `fig:"server" default:""` Insecure bool `fig:"ignoressl" default:false` + Method string `fig:"method" default:"POST"` + Params []map[string]string `fix:"params"` Headers []map[string]string `fig:"headers"` Template map[string]interface{} `fig:"template"` } @@ -245,7 +250,7 @@ func validateConfig() { // Check if Frigate server URL contains protocol, assume HTTP if not specified if !strings.Contains(ConfigData.Frigate.Server, "http://") && !strings.Contains(ConfigData.Frigate.Server, "https://") { - log.Warn().Msg("No protocol specified on Frigate Server. Assuming http://. If this is incorrect, please adjust the config file.") + log.Warn().Msgf("No protocol specified on Frigate server URL, so we'll try http://%s. If this is incorrect, please adjust the config file.", ConfigData.Frigate.Server) ConfigData.Frigate.Server = fmt.Sprintf("http://%s", ConfigData.Frigate.Server) } @@ -260,7 +265,7 @@ func validateConfig() { ConfigData.Frigate.StartupCheck.Interval = 30 } for current_attempt < ConfigData.Frigate.StartupCheck.Attempts { - response, err = util.HTTPGet(statsAPI, ConfigData.Frigate.Insecure, ConfigData.Frigate.Headers...) + response, err = util.HTTPGet(statsAPI, ConfigData.Frigate.Insecure, "", ConfigData.Frigate.Headers...) if err != nil { log.Warn(). Err(err). @@ -342,6 +347,9 @@ func validateConfig() { log.Debug().Msgf("Events without a snapshot: %v", strings.ToLower(ConfigData.Alerts.General.NoSnap)) } + // Notify_Once + log.Debug().Msgf("Notify only once per event: %v", ConfigData.Alerts.General.NotifyOnce) + // Check Zone filtering config if strings.ToLower(ConfigData.Alerts.Zones.Unzoned) != "allow" && strings.ToLower(ConfigData.Alerts.Zones.Unzoned) != "drop" { configErrors = append(configErrors, "Option for unzoned events must be 'allow' or 'drop'") @@ -406,7 +414,9 @@ func validateConfig() { } // Check / Load alerting configuration + var alertingEnabled bool if ConfigData.Alerts.Discord.Enabled { + alertingEnabled = true log.Debug().Msg("Discord alerting enabled.") if ConfigData.Alerts.Discord.Webhook == "" { configErrors = append(configErrors, "No Discord webhook specified!") @@ -417,6 +427,7 @@ func validateConfig() { } } if ConfigData.Alerts.Gotify.Enabled { + alertingEnabled = true log.Debug().Msg("Gotify alerting enabled.") // Check if Gotify server URL contains protocol, assume HTTP if not specified if !strings.Contains(ConfigData.Alerts.Gotify.Server, "http://") && !strings.Contains(ConfigData.Alerts.Gotify.Server, "https://") { @@ -435,6 +446,7 @@ func validateConfig() { } } if ConfigData.Alerts.SMTP.Enabled { + alertingEnabled = true log.Debug().Msg("SMTP alerting enabled.") if ConfigData.Alerts.SMTP.Server == "" { configErrors = append(configErrors, "No SMTP server specified!") @@ -448,12 +460,17 @@ func validateConfig() { if ConfigData.Alerts.SMTP.Port == 0 { ConfigData.Alerts.SMTP.Port = 25 } + // Copy `user` to `from` if `from` not explicitly configured + if ConfigData.Alerts.SMTP.From == "" && ConfigData.Alerts.SMTP.User != "" { + ConfigData.Alerts.SMTP.From = ConfigData.Alerts.SMTP.User + } // Check template syntax if msg := checkTemplate("SMTP", ConfigData.Alerts.SMTP.Template); msg != "" { configErrors = append(configErrors, msg) } } if ConfigData.Alerts.Telegram.Enabled { + alertingEnabled = true log.Debug().Msg("Telegram alerting enabled.") if ConfigData.Alerts.Telegram.ChatID == 0 { configErrors = append(configErrors, "No Telegram Chat ID specified!") @@ -467,6 +484,7 @@ func validateConfig() { } } if ConfigData.Alerts.Pushover.Enabled { + alertingEnabled = true log.Debug().Msg("Pushover alerting enabled.") if ConfigData.Alerts.Pushover.Token == "" { configErrors = append(configErrors, "No Pushover API token specified!") @@ -497,6 +515,7 @@ func validateConfig() { // Deprecation warning // TODO: Remove misspelled Ntfy config with v0.4.0 or later if ConfigData.Alerts.Nfty.Enabled { + alertingEnabled = true log.Warn().Msg("Config for 'nfty' will be deprecated due to misspelling. Please update config to 'ntfy'") // Copy data to new Ntfy struct ConfigData.Alerts.Ntfy.Enabled = ConfigData.Alerts.Nfty.Enabled @@ -507,6 +526,7 @@ func validateConfig() { ConfigData.Alerts.Ntfy.Template = ConfigData.Alerts.Nfty.Template } if ConfigData.Alerts.Ntfy.Enabled { + alertingEnabled = true log.Debug().Msg("Ntfy alerting enabled.") if ConfigData.Alerts.Ntfy.Server == "" { configErrors = append(configErrors, "No Ntfy server specified!") @@ -520,12 +540,17 @@ func validateConfig() { } } if ConfigData.Alerts.Webhook.Enabled { + alertingEnabled = true log.Debug().Msg("Webhook alerting enabled.") if ConfigData.Alerts.Webhook.Server == "" { configErrors = append(configErrors, "No Webhook server specified!") } } + if !alertingEnabled { + configErrors = append(configErrors, "No alerting methods have been configured. Please check config file syntax!") + } + // Validate monitoring config if ConfigData.Monitor.Enabled { log.Debug().Msg("App monitoring enabled.") diff --git a/docs/changelog.md b/docs/changelog.md index 92dde29..0be28d1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,17 @@ # Changelog +## [v0.3.5](https://github.com/0x2142/frigate-notify/releases/tag/v0.3.5) - Oct 08 2024 + - Fixed issue where built-in alert templates were not being included in binary releases + - Fixed issue where a notification may not be sent if previous event update from Frigate did not contain a snapshot + - Fixed issue where Gotify snapshot was not using `public_url` if configured + - Added `from` & `ignoressl` config items to `smtp` notifier + - Added ability to send `webhook` notifications via HTTP GET requests + - Added support for URL parameters with `webhook` notifications + - Added option to only generate a [single notification](https://frigate-notify.0x2142.com/latest/config/file/#general) per Frigate event + - Allow use of [template variables](https://frigate-notify.0x2142.com/latest/config/templates/#title-template) within alert `title` config + - New options to set specific [log level](https://frigate-notify.0x2142.com/latest/config/options/) & additional `trace` level logs for troubleshooting + - Minor enhancements to config file validation + ## [v0.3.4](https://github.com/0x2142/frigate-notify/releases/tag/v0.3.4) - Aug 15 2024 - Fixed issue where `unzoned: drop` config would drop all notifications diff --git a/docs/config/file.md b/docs/config/file.md index c393f3a..227058b 100644 --- a/docs/config/file.md +++ b/docs/config/file.md @@ -122,6 +122,7 @@ frigate: - **title** (Optional - Default: `Frigate Alert`) - Title of alert messages that are generated (Email subject, etc) + - Title value can utilize [template variables](./templates.md#available-variables) - **timeformat** (Optional - Default: `2006-01-02 15:04:05 -0700 MST`) - Optionally set a custom date/time format for notifications - This utilizes Golang's [reference time](https://go.dev/src/time/format.go) for formatting @@ -140,6 +141,9 @@ frigate: - **snap_crop** (Optional - Default: `false`) - Crops snapshot when retrieved from Frigate - Note: Per [Frigate docs](https://docs.frigate.video/integrations/api/#get-apieventsidsnapshotjpg), only applied when event is in progress +- **notify_once** (Optional - Default: `false`) + - By default, each Frigate event may generate several notifications as the object changes zones, etc + - Set this to `true` to only notify once per event ```yaml title="Config File Snippet" alerts: @@ -150,6 +154,7 @@ alerts: snap_bbox: snap_timestamp: snap_crop: + notify_once: ``` ### Quiet Hours @@ -320,12 +325,17 @@ alerts: - **password** (Optional) - Password of SMTP user - Required if `user` is set +- **from** (Optional) + - Set sender address for outgoing messages + - If left blank but authentication is configured, then `user` will be used - **recipient** (Required) - Comma-separated list of email recipients - Required if this alerting method is enabled - **template** (Optional) - Optionally specify a custom notification template - For more information on template syntax, see [Alert Templates](./templates.md#alert-templates) +- **ignoressl** (Optional - Default: `false`) + - Set to `true` to allow self-signed certificates ```yaml title="Config File Snippet" alerts: @@ -334,10 +344,12 @@ alerts: server: smtp.your.domain.tld port: 587 tls: true + from: test_user@your.domain.tld user: test_user@your.domain.tld password: test_pass recipient: nvr_group@your.domain.tld, someone_else@your.domain.tld template: + ignoressl: ``` ### Telegram @@ -500,6 +512,14 @@ alerts: - Required if this alerting method is enabled - **ignoressl** (Optional - Default: `false`) - Set to `true` to allow self-signed certificates +- **method** (Optional - Default: `POST`) + - Set HTTP method for webhook notifications + - Supports `GET` and `POST` +- **params** (Optional) + - Set optional HTTP params that will be appended to URL + - Params can utilize [template variables](./templates.md#available-variables) + - Format: `param: value` + - Example: `token: abcd1234` - **headers** (Optional) - Send additional HTTP headers to webhook receiver - Header values can utilize [template variables](./templates.md#available-variables) @@ -507,6 +527,7 @@ alerts: - Example: `Authorization: Basic abcd1234` - **template** (Optional) - Optionally specify a custom notification template + - Only applies when `method` is `POST` - For more information on template syntax, see [Alert Templates](./templates.md#alert-templates) - Note: Webhook templates **must** be valid JSON @@ -515,6 +536,8 @@ alerts: enabled: false server: ignoressl: + method: + params: headers: template: ``` diff --git a/docs/config/options.md b/docs/config/options.md index ca94b18..e935772 100644 --- a/docs/config/options.md +++ b/docs/config/options.md @@ -2,9 +2,10 @@ The following options are available as command line flags or environment variables: -| Flag | Environment Variable | Description | -|----------|----------------------|----------------------------------------------------| -| -c | FN_CONFIGFILE | Specify alternate config file location | -| -debug | FN_DEBUG | Set to `true` to enable debug logging | -| -jsonlog | FN_JSONLOG | Set to `true` to enable logging in JSON | -| -nocolor | FN_NOCOLOR | Set to `true` to disable color for console logging | +| Flag | Environment Variable | Description | +|-------------|----------------------|----------------------------------------------------------------------------------------------------------| +| -c | FN_CONFIGFILE | Specify alternate config file location | +| -debug | FN_DEBUG | Set to `true` to enable debug logging (Overrides -loglevel) | +| -loglevel | FN_LOGLEVEL | Specify desired log level: `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` (Default: `info`) | +| -jsonlog | FN_JSONLOG | Set to `true` to enable logging in JSON | +| -nocolor | FN_NOCOLOR | Set to `true` to disable color for console logging | diff --git a/docs/config/sample.md b/docs/config/sample.md index 777a4e0..2c869c8 100644 --- a/docs/config/sample.md +++ b/docs/config/sample.md @@ -37,6 +37,7 @@ alerts: snap_bbox: snap_timestamp: snap_crop: + notify_once: quiet: start: @@ -114,6 +115,8 @@ alerts: enabled: false server: ignoressl: + method: + params: headers: template: diff --git a/docs/config/templates.md b/docs/config/templates.md index 3aa46eb..8120a7e 100644 --- a/docs/config/templates.md +++ b/docs/config/templates.md @@ -1,8 +1,10 @@ # Templates +Frigate-Notify allows for certain configuration values to be customized using [Golang text templates](https://pkg.go.dev/text/template). Templates are currently supported on alert titles, alert messages, and HTTP headers. + ## Alert Templates -Frigate-Notify allows for notification messages to be customized using [Golang text templates](https://pkg.go.dev/text/template). Custom templates can be defined by configuring the `template` section of any notification provider. +Custom message templates can be defined by configuring the `template` section of any notification provider. By default, Frigate-Notify includes a few templates that it uses for building notification messages: [HTML](https://github.com/0x2142/frigate-notify/blob/main/templates/html.template), [Plaintext](https://github.com/0x2142/frigate-notify/blob/main/templates/plaintext.template), [Markdown](https://github.com/0x2142/frigate-notify/blob/main/templates/markdown.template), and [JSON](https://github.com/0x2142/frigate-notify/blob/main/templates/json.template). All of these are the same message contents, but formatted differently for different notification providers. As you define your own custom templates, it may be helpful to reference these. @@ -42,7 +44,44 @@ If the `template` configuration is missing or blank under any notification provi So instead of `template: {{ .Camera }} alert!`, use `template: "{{ .Camera }} alert!"` -### Available Variables +## Title Template + +Template variables can also be used to set the alert title or subject line. + +As an example, the following `title` template includes the camera & label information. If the camera `front_door` detected a `dog`, then the notification title would be set to: `Frigate - front_door detected dog` + +```yaml title="Config File Snippet" +... +alerts: + general: + title: Frigate - {{ .Camera }} detected {{ .Label }} +... +``` + +## Header Templates + +For alert methods that support sending custom HTTP headers, these headers can also be defined using variables using the same syntax as above. This can allow for some interesting custom behaviors. In addition, headers can load data from [environment variables](#environment-variables) for populating sensitive information like Authorization headers. + +As an example, below is an example of altering Ntfy notifications based on the event characteristics: + +```yaml title="Config File Snippet" +... + headers: + - Authorization: Basic {{ env.Getenv "FN_NTFY_AUTH_BASIC" }} + - X-Priority: "{{ if ge (len .Zones ) 1 }} 4{{ else }} 3{{ end }}" + - X-Tags: "{{ if ge (len .Zones ) 1 }} rotating_light, {{ end }}walking" +... +``` + +The above headers include an Authorization token which is collected from environment variables. They also modify the Ntfy notification priority & tags depending on whether the detected object is within a zone or not. + +Let's take a look at how this looks using the sample notification screenshots below. Before the person enters a zone, a Ntfy notification is sent with the default priority (3) & a walking person emoji. However, once that person enters into a zone, the notification is changed to a high priority (4) and includes a `rotating_light` emoji to draw attention to the alert. + +| Before object enters zone | After object enters zone | +|:-------------------------------------------------:|:-------------------------------------------------:| +| ![](../img/http_header_template_ntfy_no_zone.png) | ![](../img/http_header_template_ntfy_in_zone.png) | + +## Available Variables The list below doesn't contain every possible variable, just a few of the most common. Most come from the event payload received from Frigate, but a few extras have been added to help make building templates easier. @@ -64,7 +103,7 @@ The list below doesn't contain every possible variable, just a few of the most c | .Extra.LocalURL | Frigate server URL as specified under `frigate > server` | | .Extra.PublicURL | Frigate Public URL as specified under `frigate > public_url` | -### Environment variables +## Environment variables Templates can also retrieve values from environment variables using a built-in `env` function. Environment variables used within templates must contain the `FN_` prefix. diff --git a/docs/img/http_header_template_ntfy_in_zone.png b/docs/img/http_header_template_ntfy_in_zone.png new file mode 100644 index 0000000..9ef3590 Binary files /dev/null and b/docs/img/http_header_template_ntfy_in_zone.png differ diff --git a/docs/img/http_header_template_ntfy_no_zone.png b/docs/img/http_header_template_ntfy_no_zone.png new file mode 100644 index 0000000..506c6ff Binary files /dev/null and b/docs/img/http_header_template_ntfy_no_zone.png differ diff --git a/events/api.go b/events/api.go index 75a4d87..9cd93d4 100644 --- a/events/api.go +++ b/events/api.go @@ -15,7 +15,6 @@ import ( "github.com/0x2142/frigate-notify/models" "github.com/0x2142/frigate-notify/notifier" "github.com/0x2142/frigate-notify/util" - "golang.org/x/exp/slices" ) const eventsURI = "/api/events" @@ -39,7 +38,7 @@ func CheckForEvents() { log.Debug().Msg("Checking for new events...") // Query events - response, err := util.HTTPGet(url, config.ConfigData.Frigate.Insecure, config.ConfigData.Frigate.Headers...) + response, err := util.HTTPGet(url, config.ConfigData.Frigate.Insecure, "", config.ConfigData.Frigate.Headers...) if err != nil { log.Error(). Err(err). @@ -61,15 +60,6 @@ func CheckForEvents() { LastEventTime = event.StartTime } - // Skip excluded cameras - if slices.Contains(config.ConfigData.Frigate.Cameras.Exclude, event.Camera) { - log.Debug(). - Str("event_id", event.ID). - Str("camera", event.Camera). - Msg("Skipping event from excluded camera") - continue - } - log.Info(). Str("event_id", event.ID). Str("camera", event.Camera). @@ -95,7 +85,7 @@ func CheckForEvents() { } // Send alert with snapshot - notifier.SendAlert(event, snapshotURL, snapshot, event.ID) + notifier.SendAlert(event, snapshot, event.ID) } } @@ -115,7 +105,7 @@ func GetSnapshot(snapshotURL, eventID string) io.Reader { q.Add("crop", "1") } url.RawQuery = q.Encode() - response, err := util.HTTPGet(url.String(), config.ConfigData.Frigate.Insecure, config.ConfigData.Frigate.Headers...) + response, err := util.HTTPGet(url.String(), config.ConfigData.Frigate.Insecure, "", config.ConfigData.Frigate.Headers...) if err != nil { log.Warn(). Str("event_id", eventID). diff --git a/events/cache.go b/events/cache.go new file mode 100644 index 0000000..3c5b918 --- /dev/null +++ b/events/cache.go @@ -0,0 +1,115 @@ +package frigate + +import ( + "slices" + "strings" + "time" + + "github.com/0x2142/frigate-notify/models" + "github.com/rs/zerolog/log" + + "github.com/maypok86/otter" +) + +var zoneCache otter.Cache[string, []string] + +func InitZoneCache() { + var err error + log.Debug().Msg("Setting up zone cache...") + zoneCache, err = otter.MustBuilder[string, []string](500).WithTTL(1 * time.Hour).Build() + if err != nil { + log.Warn(). + Err(err). + Msg("Error setting up zone cache") + } + log.Debug().Msg("Zone cache ready") +} + +func CloseZoneCache() { + log.Debug().Msg("Cache tear down") + zoneCache.Close() +} + +// Add zone to list of zones that have already generated notifications for specified event ID +func setZoneAlerted(event models.Event) { + // Get current list of zones by event ID, if it exists + alreadyAlerted, _ := zoneCache.Get(event.ID) + log.Trace(). + Strs("cache", alreadyAlerted). + Str("event_id", event.ID). + Msg("Current cache contents") + alreadyAlerted = append(alreadyAlerted, event.CurrentZones...) + // Remove duplicates + slices.Sort(alreadyAlerted) + alreadyAlerted = slices.Compact(alreadyAlerted) + // Update cache with new list + zoneCache.Set(event.ID, alreadyAlerted) + log.Trace(). + Strs("cache", alreadyAlerted). + Str("event_id", event.ID). + Msg("New cache contents") +} + +// Query cache by event ID +func getCachebyID(id string) []string { + cacheData, ok := zoneCache.Get(id) + log.Trace(). + Bool("in_cache", ok). + Strs("cache", cacheData). + Str("event_id", id). + Msgf("Get event from cache") + if !ok { + return nil + } + return cacheData +} + +// Query cache to see if zone already generated alert +func zoneAlreadyAlerted(event models.Event) bool { + // Check if event already in cache & if so, get contents + alreadyAlerted, ok := zoneCache.Get(event.ID) + log.Trace(). + Bool("in_cache", ok). + Strs("cache", alreadyAlerted). + Str("event_id", event.ID). + Msgf("Get event from cache") + // If event not found, create cache entry & add zones + if !ok { + log.Debug(). + Str("event_id", event.ID). + Str("camera", event.Camera). + Str("zones", strings.Join(event.CurrentZones, ",")). + Msg("Event not in cache, adding...") + setZoneAlerted(event) + return false + } + // If event found, check to see if there are any new zones to notify on + for _, zone := range event.CurrentZones { + if !slices.Contains(alreadyAlerted, zone) { + log.Debug(). + Str("event_id", event.ID). + Str("camera", event.Camera). + Str("zones", strings.Join(event.CurrentZones, ",")). + Msg("Found new zone not in cache") + setZoneAlerted(event) + return false + } + } + // If no new zones, then assume all have been notified already + log.Debug(). + Str("event_id", event.ID). + Str("camera", event.Camera). + Str("zones", strings.Join(event.CurrentZones, ",")). + Msg("All zones in event have already notified") + return true +} + +// Remove zone alert cache for event ID +func delZoneAlerted(event models.Event) { + zoneCache.Delete(event.ID) + log.Debug(). + Str("event_id", event.ID). + Str("camera", event.Camera). + Str("zones", strings.Join(event.CurrentZones, ",")). + Msg("Event removed from cache") +} diff --git a/events/cache_test.go b/events/cache_test.go new file mode 100644 index 0000000..0518c26 --- /dev/null +++ b/events/cache_test.go @@ -0,0 +1,95 @@ +package frigate + +import ( + "testing" + + "github.com/0x2142/frigate-notify/models" +) + +func TestSetZoneAlerted(t *testing.T) { + // Setup + InitZoneCache() + defer CloseZoneCache() + event := models.Event{ID: "test-event-id", CurrentZones: []string{"test_zone", "test_zone"}} + + setZoneAlerted(event) + + expected := []string{"test_zone"} + result, ok := zoneCache.Get(event.ID) + if !ok { + t.Error("Could not find event ID") + } + + // Check if zone added + if result[0] != expected[0] { + t.Errorf("Expected: %s, Got: %s", expected, result) + } + + // Check if duplicates removed + if len(result) != 1 { + t.Errorf("Expected: %s, Got: %s", expected, result) + } +} + +func TestGetCachebyID(t *testing.T) { + // Setup + InitZoneCache() + defer CloseZoneCache() + event := models.Event{ID: "test-event-id", CurrentZones: []string{"test_zone"}} + setZoneAlerted(event) + + // Check event in cache + result := getCachebyID(event.ID) + if result == nil { + t.Errorf("Expected: ['test_zone'], Got: %v", result) + } + + // Check non-existent event + result = getCachebyID("1234") + if result != nil { + t.Errorf("Expected: nil, Got: %v", result) + } +} + +func TestZoneAlreadyAlerted(t *testing.T) { + // Setup + InitZoneCache() + defer CloseZoneCache() + event := models.Event{ID: "test-event-id", CurrentZones: []string{"test_zone", "test_zone"}} + + // Test new event + result := zoneAlreadyAlerted(event) + if result != false { + t.Errorf("Expected: false, Got: %v", result) + } + + // Test adding new zone to existing event + event.CurrentZones = append(event.CurrentZones, "another_zone") + result = zoneAlreadyAlerted(event) + if result != false { + t.Errorf("Expected: false, Got: %v", result) + } + + // Test event that has already generated alert + result = zoneAlreadyAlerted(event) + if result != true { + t.Errorf("Expected: true, Got: %v", result) + } +} + +func TestDelZoneAlerted(t *testing.T) { + // Setup + InitZoneCache() + defer CloseZoneCache() + event := models.Event{ID: "test-event-id", CurrentZones: []string{"test_zone", "test_zone"}} + + // Create new cache entry + setZoneAlerted(event) + + // Test delete + delZoneAlerted(event) + _, ok := zoneCache.Get(event.ID) + if ok { + t.Errorf("Cache entry not deleted") + } +} diff --git a/events/filters.go b/events/filters.go index 5f91b6e..11ec59d 100644 --- a/events/filters.go +++ b/events/filters.go @@ -13,6 +13,40 @@ import ( // checkEventFilters processes incoming event through configured filters to determine if it should generate a notification func checkEventFilters(event models.Event) bool { + // Skip excluded cameras + if slices.Contains(config.ConfigData.Frigate.Cameras.Exclude, event.Camera) { + log.Info(). + Str("event_id", event.ID). + Str("camera", event.Camera). + Msg("Event dropped - Camera Excluded") + return false + } + // Drop event if no snapshot or clip is available - Event is likely being filtered on Frigate side. + // For example, if a camera has `required_zones` set - then there may not be any clip or snap until + // object moves into required zone + if !event.HasClip && !event.HasSnapshot { + log.Info(). + Str("event_id", event.ID). + Msg("Event dropped - No snapshot or clip available") + return false + } + // Check if notify_once is set & we already notified on this event + if config.ConfigData.Alerts.General.NotifyOnce { + // Check if cache already contains event ID + if getCachebyID(event.ID) != nil { + log.Info(). + Str("event_id", event.ID). + Msg("Event dropped - Already notified & notify_once is set") + return false + } + } + // Drop event if no snapshot & skip_nosnap is true + if !event.HasSnapshot && strings.ToLower(config.ConfigData.Alerts.General.NoSnap) == "drop" { + log.Info(). + Str("event_id", event.ID). + Msg("Event dropped - No snapshot available") + return false + } // Check quiet hours if isQuietHours() { log.Info(). @@ -53,6 +87,11 @@ func isQuietHours() bool { currentTime, _ := time.Parse("15:04:05", time.Now().Format("15:04:05")) start, _ := time.Parse("15:04", config.ConfigData.Alerts.Quiet.Start) end, _ := time.Parse("15:04", config.ConfigData.Alerts.Quiet.End) + log.Trace(). + Time("current_time", currentTime). + Time("quiet_start", start). + Time("quiet_end", end). + Msg("Check quiet hours") // Check if quiet period is overnight if end.Before(start) { if currentTime.After(start) || currentTime.Before(end) { @@ -68,6 +107,13 @@ func isQuietHours() bool { // isAllowedZone verifies whether a zone should be allowed to generate a notification func isAllowedZone(id string, zones []string) bool { + log.Trace(). + Str("event_id", id). + Strs("zones", zones). + Str("allow_unzoned", config.ConfigData.Alerts.Zones.Unzoned). + Strs("blocked", config.ConfigData.Alerts.Zones.Block). + Strs("allowed", config.ConfigData.Alerts.Zones.Allow). + Msg("Check allowed zone") // By default, send events without a zone unless specified otherwise if strings.ToLower(config.ConfigData.Alerts.Zones.Unzoned) == "drop" && len(zones) == 0 { log.Info(). @@ -113,10 +159,22 @@ func isAllowedLabel(id string, label string, kind string) bool { if kind == "label" { blocked = config.ConfigData.Alerts.Labels.Block allowed = config.ConfigData.Alerts.Labels.Allow + log.Trace(). + Str("event_id", id). + Str("label", label). + Strs("blocked", blocked). + Strs("allowed", allowed). + Msg("Check allowed label") } if kind == "sublabel" { blocked = config.ConfigData.Alerts.SubLabels.Block allowed = config.ConfigData.Alerts.SubLabels.Allow + log.Trace(). + Str("event_id", id). + Str("label", label). + Strs("blocked", blocked). + Strs("allowed", allowed). + Msg("Check allowed sublabel") } // Check block list if slices.Contains(blocked, label) { @@ -146,6 +204,11 @@ func isAllowedLabel(id string, label string, kind string) bool { // aboveMinScore checks if label score is above configured minimum func aboveMinScore(id string, score float64) bool { score = score * 100 + log.Trace(). + Str("event_id", id). + Float64("event_score", score). + Float64("min_score", score). + Msg("Check minimum score") if score >= config.ConfigData.Alerts.Labels.MinScore { return true } else { diff --git a/events/mqtt.go b/events/mqtt.go index 8e1c10b..5e3517b 100644 --- a/events/mqtt.go +++ b/events/mqtt.go @@ -13,14 +13,14 @@ import ( "github.com/0x2142/frigate-notify/models" "github.com/0x2142/frigate-notify/notifier" mqtt "github.com/eclipse/paho.mqtt.golang" - "golang.org/x/exp/slices" ) // SubscribeMQTT establishes subscription to MQTT server & listens for messages func SubscribeMQTT() { // MQTT client configuration + mqttServer := fmt.Sprintf("tcp://%s:%d", config.ConfigData.Frigate.MQTT.Server, config.ConfigData.Frigate.MQTT.Port) opts := mqtt.NewClientOptions() - opts.AddBroker(fmt.Sprintf("tcp://%s:%d", config.ConfigData.Frigate.MQTT.Server, config.ConfigData.Frigate.MQTT.Port)) + opts.AddBroker(mqttServer) opts.SetClientID(config.ConfigData.Frigate.MQTT.ClientID) opts.SetAutoReconnect(true) opts.SetConnectionLostHandler(connectionLostHandler) @@ -30,6 +30,15 @@ func SubscribeMQTT() { opts.SetPassword(config.ConfigData.Frigate.MQTT.Password) } + log.Trace(). + Str("server", mqttServer). + Str("client_id", config.ConfigData.Frigate.MQTT.ClientID). + Str("username", config.ConfigData.Frigate.MQTT.Username). + Str("password", "--secret removed--"). + Str("topic", config.ConfigData.Frigate.MQTT.TopicPrefix+"/events"). + Bool("auto_reconnect", true). + Msg("Init MQTT connection") + var subscribed = false var retry = 0 for !subscribed { @@ -56,6 +65,10 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { var event models.MQTTEvent json.Unmarshal(msg.Payload(), &event) + log.Trace(). + RawJSON("payload", msg.Payload()). + Msg("MQTT event received") + if event.Type == "new" || event.Type == "update" { if event.Type == "new" { log.Info(). @@ -66,14 +79,6 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { Str("event_id", event.After.ID). Msg("Event update received") } - // Skip excluded cameras - if slices.Contains(config.ConfigData.Frigate.Cameras.Exclude, event.After.Camera) { - log.Info(). - Str("event_id", event.After.ID). - Str("camera", event.After.Camera). - Msg("Event dropped - Camera Excluded") - return - } // Convert to human-readable timestamp eventTime := time.Unix(int64(event.After.StartTime), 0) @@ -92,21 +97,8 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { return } - // Skip update events where zone didn't change - // Compares current detected zone to previous list of zones entered - zoneChanged := false - for _, zone := range event.After.CurrentZones { - if !slices.Contains(event.Before.EnteredZones, zone) { - zoneChanged = true - log.Debug(). - Str("event_id", event.After.ID). - Str("camera", event.After.Camera). - Str("label", event.After.Label). - Str("zones", strings.Join(event.After.CurrentZones, ",")). - Msg("Object entered new zone") - } - } - if event.Type == "update" && !zoneChanged { + // Check if already notified on zones + if zoneAlreadyAlerted(event.After.Event) { log.Info(). Str("event_id", event.After.ID). Str("camera", event.After.Camera). @@ -114,6 +106,13 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { Str("zones", strings.Join(event.After.CurrentZones, ",")). Msg("Event dropped - Already notified on this zone") return + } else { + log.Debug(). + Str("event_id", event.After.ID). + Str("camera", event.After.Camera). + Str("label", event.After.Label). + Str("zones", strings.Join(event.After.CurrentZones, ",")). + Msg("Object entered new zone") } // If snapshot was collected, pull down image to send with alert @@ -125,7 +124,16 @@ func processEvent(client mqtt.Client, msg mqtt.Message) { } // Send alert with snapshot - notifier.SendAlert(event.After.Event, snapshotURL, snapshot, event.After.ID) + notifier.SendAlert(event.After.Event, snapshot, event.After.ID) + } + + // Clear event cache entry when event ends + if event.Type == "end" { + log.Debug(). + Str("event_id", event.After.ID). + Msg("Event ended") + delZoneAlerted(event.After.Event) + return } } diff --git a/example-config.yml b/example-config.yml index 88c4916..8b0dea9 100644 --- a/example-config.yml +++ b/example-config.yml @@ -7,7 +7,7 @@ frigate: # Frigate host URL (ex. https://frigate.yourdomain.tld) # This is required for both collection methods server: - # Set to true if using SSL & a self-signed certificate + # Set to true to allow self-signed certificates ignoressl: false # Public / internet-facing Frigate URL, if different from above server address public_url: @@ -58,6 +58,7 @@ alerts: # General config applies to all alert methods below general: # Title for any alert messages (Default: Frigate Alert) + # Supports template variables title: # Optionally modify default time format in notifications # Use Golang's reference time format, or see docs for more info @@ -72,6 +73,10 @@ alerts: snap_timestamp: # Set to `true` to crop snapshot snap_crop: + # By default, each Frigate event may generate several notifications as the object changes zones, etc + # Set this to `true` to only notify once per event + notify_once: + # If configured, ignore events between times below quiet: @@ -120,7 +125,7 @@ alerts: server: # Application token generated by Gotify token: - # Set to true if using SSL & a self-signed certificate + # Set to true to allow self-signed certificates ignoressl: # Custom notification template, if desired template: @@ -134,14 +139,18 @@ alerts: port: # Whether or not the SMTP server requires TLS (Default: true) tls: - # Sending address / username + # Username for authentication user: # SMTP password for above user password: + # Sending email address + from: # Email alerts sent to any addresses listed below, separated by comma recipient: # Custom notification template, if desired template: + # Set to true to allow self-signed certificates + ignoressl: # Telegram Config telegram: @@ -183,7 +192,7 @@ alerts: server: # Ntfy topic for notifications topic: - # Set to true if using SSL & a self-signed certificate + # Set to true to allow self-signed certificates ignoressl: # List of HTTP headers to send to Ntfy, in format Header: Value headers: @@ -198,8 +207,14 @@ alerts: enabled: false # URL of webhook receiver server: - # Set to true if using SSL & a self-signed certificate + # Set to true to allow self-signed certificates ignoressl: + # HTTP Method to send notifications, supports GET or POST (Default: POST) + method: + # Optional list of HTTP parameters to append to URL + params: + # Example: + # - token: abcd1234 # List of HTTP headers to send to webhook receiver, in format Header: Value headers: # Example: @@ -217,5 +232,5 @@ monitor: url: # Interval between monitoring check-in events, in seconds interval: - # Set to true if using SSL & a self-signed certificate + # Set to true to allow self-signed certificates ignoressl: diff --git a/go.mod b/go.mod index dab3468..af7cff9 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,13 @@ require ( require ( github.com/disgoorg/json v1.1.0 // indirect github.com/disgoorg/snowflake/v2 v2.0.1 // indirect + github.com/dolthub/maphash v0.1.0 // indirect + github.com/gammazero/deque v0.2.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/maypok86/otter v1.2.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect diff --git a/go.sum b/go.sum index 1eb9052..68be7cc 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,12 @@ github.com/disgoorg/json v1.1.0 h1:7xigHvomlVA9PQw9bMGO02PHGJJPqvX5AnwlYg/Tnys= github.com/disgoorg/json v1.1.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA= github.com/disgoorg/snowflake/v2 v2.0.1 h1:CuUxGLwggUxEswZOmZ+mZ5i0xSumQdXW9tXW7uGqe+0= github.com/disgoorg/snowflake/v2 v2.0.1/go.mod h1:SPU9c2CNn5DSyb86QcKtdZgix9osEtKrHLW4rMhfLCs= +github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= +github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= +github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0= +github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU= 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -33,6 +37,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maypok86/otter v1.2.2 h1:jJi0y8ruR/ZcKmJ4FbQj3QQTqKwV+LNrSOo2S1zbF5M= +github.com/maypok86/otter v1.2.2/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= diff --git a/main.go b/main.go index 37dbf0a..907d548 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,11 @@ package main import ( + "embed" "flag" "os" "os/signal" + "strings" "time" _ "time/tzdata" @@ -12,19 +14,25 @@ import ( "github.com/0x2142/frigate-notify/config" frigate "github.com/0x2142/frigate-notify/events" + "github.com/0x2142/frigate-notify/notifier" "github.com/0x2142/frigate-notify/util" ) -var APP_VER = "v0.3.4" +var APP_VER = "v0.3.5" var debug, debugenv bool var jsonlog, jsonlogenv bool var nocolor, nocolorenv bool var configFile string +var logLevel string + +//go:embed templates/* +var NotifTemplates embed.FS func main() { // Parse flags flag.StringVar(&configFile, "c", "", "Configuration file location (default \"./config.yml\")") - flag.BoolVar(&debug, "debug", false, "Enable debug logging") + flag.BoolVar(&debug, "debug", false, "Enable debug logging (Overrides loglevel, if also set)") + flag.StringVar(&logLevel, "loglevel", "info", "Set logging level") flag.BoolVar(&jsonlog, "jsonlog", false, "Enable JSON logging") flag.BoolVar(&nocolor, "nocolor", false, "Disable color on console logging") flag.Parse() @@ -37,7 +45,26 @@ func main() { _, nocolorenv = os.LookupEnv("FN_NOCOLOR") log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "2006/01/02 15:04:05 -0700", NoColor: nocolorenv || nocolor}) } - zerolog.SetGlobalLevel(zerolog.InfoLevel) + + // Apply custom log level, if set + switch logLevel, _ = os.LookupEnv("FN_LOGLEVEL"); strings.ToLower(logLevel) { + case "panic": + zerolog.SetGlobalLevel(zerolog.PanicLevel) + case "fatal": + zerolog.SetGlobalLevel(zerolog.FatalLevel) + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + case "debug": + log.Debug().Msg("Debug logging enabled") + zerolog.SetGlobalLevel(zerolog.DebugLevel) + case "trace": + log.Trace().Msg("Trace logging enabled") + zerolog.SetGlobalLevel(zerolog.TraceLevel) + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } // Enable debug logging if set _, debugenv = os.LookupEnv("FN_DEBUG") @@ -54,12 +81,14 @@ func main() { // Load & validate config config.LoadConfig(configFile) + notifier.TemplateFiles = NotifTemplates + // Set up monitor if config.ConfigData.Monitor.Enabled { log.Debug().Msg("App monitoring enabled.") go func() { for { - _, err := util.HTTPGet(config.ConfigData.Monitor.URL, config.ConfigData.Monitor.Insecure) + _, err := util.HTTPGet(config.ConfigData.Monitor.URL, config.ConfigData.Monitor.Insecure, "") if err != nil { log.Warn(). Err(err). @@ -71,9 +100,12 @@ func main() { }() } + // Set up event cache + frigate.InitZoneCache() + // Loop & watch for events if config.ConfigData.Frigate.WebAPI.Enabled { - log.Info().Msg("App running. Press Ctrl-C to quit.") + log.Info().Msg("App ready!") for { frigate.CheckForEvents() time.Sleep(time.Duration(config.ConfigData.Frigate.WebAPI.Interval) * time.Second) @@ -81,9 +113,11 @@ func main() { } // Connect MQTT if config.ConfigData.Frigate.MQTT.Enabled { + defer frigate.CloseZoneCache() + log.Debug().Msg("Connecting to MQTT Server...") frigate.SubscribeMQTT() - log.Info().Msg("App running. Press Ctrl-C to quit.") + log.Info().Msg("App ready!") sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig diff --git a/notifier/alerts.go b/notifier/alerts.go index 5a02334..e7f9902 100644 --- a/notifier/alerts.go +++ b/notifier/alerts.go @@ -2,6 +2,7 @@ package notifier import ( "bytes" + "embed" "fmt" "io" "os" @@ -15,26 +16,12 @@ import ( "github.com/0x2142/frigate-notify/models" ) +var TemplateFiles embed.FS + // SendAlert forwards alert information to all enabled alerting methods -func SendAlert(event models.Event, snapshotURL string, snapshot io.Reader, eventid string) { +func SendAlert(event models.Event, snapshot io.Reader, eventid string) { // Add Frigate Major version metadata event.Extra.FrigateMajorVersion = config.ConfigData.Frigate.Version - // Drop event if no snapshot or clip is available - Event is likely being filtered on Frigate side. - // For example, if a camera has `required_zones` set - then there may not be any clip or snap until - // object moves into required zone - if !event.HasClip && !event.HasSnapshot { - log.Info(). - Str("event_id", event.ID). - Msg("Event dropped - No snapshot or clip available") - return - } - // Drop event if no snapshot & skip_nosnap is true - if !event.HasSnapshot && strings.ToLower(config.ConfigData.Alerts.General.NoSnap) == "drop" { - log.Info(). - Str("event_id", event.ID). - Msg("Event dropped - No snapshot available") - return - } // Create copy of snapshot for each alerting method var snap []byte if snapshot != nil { @@ -44,7 +31,7 @@ func SendAlert(event models.Event, snapshotURL string, snapshot io.Reader, event go SendDiscordMessage(event, bytes.NewReader(snap)) } if config.ConfigData.Alerts.Gotify.Enabled { - go SendGotifyPush(event, snapshotURL) + go SendGotifyPush(event) } if config.ConfigData.Alerts.SMTP.Enabled { go SendSMTP(event, bytes.NewReader(snap)) @@ -101,8 +88,7 @@ func renderMessage(sourceTemplate string, event models.Event) string { var tmpl *template.Template var err error if sourceTemplate == "markdown" || sourceTemplate == "plaintext" || sourceTemplate == "html" || sourceTemplate == "json" { - var templateFile = "./templates/" + sourceTemplate + ".template" - tmpl = template.Must(template.ParseFiles(templateFile)) + tmpl = template.Must(template.ParseFS(TemplateFiles, "templates/"+sourceTemplate+".template")) } else { tmpl, err = template.New("custom").Funcs(template.FuncMap{"env": includeenv}).Parse(sourceTemplate) if err != nil { @@ -122,17 +108,18 @@ func renderMessage(sourceTemplate string, event models.Event) string { } -// Build HTTP headers based on template -func renderHeaders(headers []map[string]string, event models.Event) []map[string]string { +// Build HTTP headers or params based on template +func renderHTTPKV(list []map[string]string, event models.Event, kvtype string) []map[string]string { event = setExtras(event) - var newHeaders []map[string]string - for _, header := range headers { - for k, v := range header { + var renderedList []map[string]string + + for _, item := range list { + for k, v := range item { // Render tmpl, err := template.New("custom").Funcs(template.FuncMap{"env": includeenv}).Parse(v) if err != nil { - log.Warn().Err(err).Msg("Failed to render HTTP header") + log.Warn().Err(err).Msgf("Failed to render HTTP %s", kvtype) } var renderedTemplate bytes.Buffer @@ -140,15 +127,15 @@ func renderHeaders(headers []map[string]string, event models.Event) []map[string if err != nil { log.Fatal(). Err(err). - Msgf("Failed to render HTTP header") + Msgf("Failed to render HTTP %s", kvtype) } v = renderedTemplate.String() - newHeaders = append(newHeaders, map[string]string{k: v}) + renderedList = append(renderedList, map[string]string{k: v}) } } - return newHeaders + return renderedList } // includeenv retrieves environment variables for use within templates diff --git a/notifier/discord.go b/notifier/discord.go index 962910a..bd0ce46 100644 --- a/notifier/discord.go +++ b/notifier/discord.go @@ -40,18 +40,28 @@ func SendDiscordMessage(event models.Event, snapshot io.Reader) { } defer client.Close(context.TODO()) - title := fmt.Sprintf("**%v**\n\n", config.ConfigData.Alerts.General.Title) + title := renderMessage(config.ConfigData.Alerts.General.Title, event) + title = fmt.Sprintf("**%v**\n\n", title) message = title + message // Send alert & attach snapshot if one was saved + var msg *discord.Message if event.HasSnapshot { image := discord.NewFile("snapshot.jpg", "", snapshot) embed := discord.NewEmbedBuilder().SetDescription(message).SetTitle(title).SetImage("attachment://snapshot.jpg").SetColor(5793266).Build() - _, err = client.CreateMessage(discord.NewWebhookMessageCreateBuilder().SetEmbeds(embed).SetFiles(image).Build()) + msg, err = client.CreateMessage(discord.NewWebhookMessageCreateBuilder().SetEmbeds(embed).SetFiles(image).Build()) + log.Trace(). + Str("event_id", event.ID). + Interface("payload", msg). + Msg("Send Discord Alert") + } else { embed := discord.NewEmbedBuilder().SetDescription(message).SetTitle(title).SetColor(5793266).Build() - _, err = client.CreateMessage(discord.NewWebhookMessageCreateBuilder().SetEmbeds(embed).Build()) - + msg, err = client.CreateMessage(discord.NewWebhookMessageCreateBuilder().SetEmbeds(embed).Build()) + log.Trace(). + Str("event_id", event.ID). + Interface("payload", msg). + Msg("Send Discord Alert") } if err != nil { log.Warn(). diff --git a/notifier/gotify.go b/notifier/gotify.go index 0cfe36a..a3dd33a 100644 --- a/notifier/gotify.go +++ b/notifier/gotify.go @@ -34,8 +34,17 @@ type gotifyPayload struct { } `json:"extras,omitempty"` } +const eventsURI = "/api/events" +const snapshotURI = "/snapshot.jpg" + // SendGotifyPush forwards alert messages to Gotify push notification server -func SendGotifyPush(event models.Event, snapshotURL string) { +func SendGotifyPush(event models.Event) { + var snapshotURL string + if config.ConfigData.Frigate.PublicURL != "" { + snapshotURL = config.ConfigData.Frigate.PublicURL + eventsURI + "/" + event.ID + snapshotURI + } else { + snapshotURL = config.ConfigData.Frigate.Server + eventsURI + "/" + event.ID + snapshotURI + } // Build notification var message string if config.ConfigData.Alerts.Gotify.Template != "" { @@ -52,9 +61,10 @@ func SendGotifyPush(event models.Event, snapshotURL string) { if event.HasSnapshot { message += fmt.Sprintf("\n\n![](%s)", snapshotURL) } + title := renderMessage(config.ConfigData.Alerts.General.Title, event) payload := gotifyPayload{ Message: message, - Title: config.ConfigData.Alerts.General.Title, + Title: title, Priority: 5, } payload.Extras.ClientDisplay.ContentType = "text/markdown" @@ -73,7 +83,7 @@ func SendGotifyPush(event models.Event, snapshotURL string) { gotifyURL := fmt.Sprintf("%s/message?token=%s&", config.ConfigData.Alerts.Gotify.Server, config.ConfigData.Alerts.Gotify.Token) header := map[string]string{"Content-Type": "application/json"} - response, err := util.HTTPPost(gotifyURL, config.ConfigData.Alerts.Gotify.Insecure, data, header) + response, err := util.HTTPPost(gotifyURL, config.ConfigData.Alerts.Gotify.Insecure, data, "", header) if err != nil { log.Warn(). Str("event_id", event.ID). diff --git a/notifier/nfty.go b/notifier/nfty.go index 218cc04..1b98648 100644 --- a/notifier/nfty.go +++ b/notifier/nfty.go @@ -30,9 +30,10 @@ func SendNtfyPush(event models.Event, snapshot io.Reader) { NtfyURL := fmt.Sprintf("%s/%s", config.ConfigData.Alerts.Ntfy.Server, config.ConfigData.Alerts.Ntfy.Topic) // Set headers + title := renderMessage(config.ConfigData.Alerts.General.Title, event) 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}) + headers = append(headers, map[string]string{"X-Title": title}) headers = append(headers, config.ConfigData.Alerts.Ntfy.Headers...) // Set action link to the recorded clip @@ -65,9 +66,9 @@ func SendNtfyPush(event models.Event, snapshot io.Reader) { headers = append(headers, map[string]string{"X-Actions": "view, View Clip, " + clip + ", clear=true"}) } - headers = renderHeaders(headers, event) + headers = renderHTTPKV(headers, event, "headers") - resp, err := util.HTTPPost(NtfyURL, config.ConfigData.Alerts.Ntfy.Insecure, attachment, headers...) + resp, err := util.HTTPPost(NtfyURL, config.ConfigData.Alerts.Ntfy.Insecure, attachment, "", headers...) if err != nil { log.Warn(). Str("event_id", event.ID). diff --git a/notifier/pushover.go b/notifier/pushover.go index fa4a64f..7b2cdc5 100644 --- a/notifier/pushover.go +++ b/notifier/pushover.go @@ -32,9 +32,10 @@ func SendPushoverMessage(event models.Event, snapshot io.Reader) { recipient := pushover.NewRecipient(config.ConfigData.Alerts.Pushover.Userkey) // Create new message + title := renderMessage(config.ConfigData.Alerts.General.Title, event) notif := &pushover.Message{ Message: message, - Title: config.ConfigData.Alerts.General.Title, + Title: title, Priority: config.ConfigData.Alerts.Pushover.Priority, HTML: true, TTL: time.Duration(config.ConfigData.Alerts.Pushover.TTL) * time.Second, @@ -52,10 +53,19 @@ func SendPushoverMessage(event models.Event, snapshot io.Reader) { notif.DeviceName = devices } + log.Trace(). + Interface("payload", notif). + Interface("recipient", "--secret removed--"). + Msg("Send Pushover alert") + // Send notification if event.HasSnapshot { notif.AddAttachment(snapshot) - if _, err := push.SendMessage(notif, recipient); err != nil { + response, err := push.SendMessage(notif, recipient) + log.Trace(). + Interface("payload", response). + Msg("Pushover response") + if err != nil { log.Warn(). Str("event_id", event.ID). Str("provider", "Pushover"). @@ -63,7 +73,11 @@ func SendPushoverMessage(event models.Event, snapshot io.Reader) { return } } else { - if _, err := push.SendMessage(notif, recipient); err != nil { + response, err := push.SendMessage(notif, recipient) + log.Trace(). + Interface("payload", response). + Msg("Pushover response") + if err != nil { log.Warn(). Str("event_id", event.ID). Str("provider", "Pushover"). diff --git a/notifier/smtp.go b/notifier/smtp.go index 1c09c70..748f735 100644 --- a/notifier/smtp.go +++ b/notifier/smtp.go @@ -1,6 +1,7 @@ package notifier import ( + "crypto/tls" "io" "strings" "time" @@ -29,9 +30,10 @@ func SendSMTP(event models.Event, snapshot io.Reader) { // Set up email alert m := mail.NewMsg() - m.From(config.ConfigData.Alerts.SMTP.User) + m.From(config.ConfigData.Alerts.SMTP.From) m.To(ParseSMTPRecipients()...) - m.Subject(config.ConfigData.Alerts.General.Title) + title := renderMessage(config.ConfigData.Alerts.General.Title, event) + m.Subject(title) // Attach snapshot if one exists if event.HasSnapshot { m.AttachReader("snapshot.jpg", snapshot) @@ -54,6 +56,10 @@ func SendSMTP(event models.Event, snapshot io.Reader) { if !config.ConfigData.Alerts.SMTP.TLS { c.SetTLSPolicy(mail.NoTLS) } + // Disable certificate verification if needed + if config.ConfigData.Alerts.SMTP.Insecure { + c.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) + } if err != nil { log.Warn(). @@ -63,6 +69,18 @@ func SendSMTP(event models.Event, snapshot io.Reader) { Msg("Unable to send alert") } + log.Trace(). + Strs("sender", m.GetFromString()). + Strs("recipients", m.GetToString()). + Str("subject", title). + Interface("payload", message). + Str("server", config.ConfigData.Alerts.SMTP.Server). + Int("port", config.ConfigData.Alerts.SMTP.Port). + Bool("tls", config.ConfigData.Alerts.SMTP.TLS). + Str("username", config.ConfigData.Alerts.SMTP.User). + Str("password", "--secret removed--"). + Msg("Send SMTP Alert") + // Send message if err := c.DialAndSend(m); err != nil { log.Warn(). diff --git a/notifier/telegram.go b/notifier/telegram.go index 110e4c4..58507ae 100644 --- a/notifier/telegram.go +++ b/notifier/telegram.go @@ -42,7 +42,11 @@ func SendTelegramMessage(event models.Event, snapshot io.Reader) { photo := tgbotapi.NewPhoto(config.ConfigData.Alerts.Telegram.ChatID, tgbotapi.FileReader{Name: "Snapshot", Reader: snapshot}) photo.Caption = message photo.ParseMode = "HTML" - if _, err := bot.Send(photo); err != nil { + response, err := bot.Send(photo) + log.Trace(). + Interface("content", response). + Msg("Send Telegram Alert") + if err != nil { log.Warn(). Str("event_id", event.ID). Str("provider", "Telegram"). @@ -54,7 +58,11 @@ func SendTelegramMessage(event models.Event, snapshot io.Reader) { // Send plain text message if no snapshot available msg := tgbotapi.NewMessage(config.ConfigData.Alerts.Telegram.ChatID, message) msg.ParseMode = "HTML" - if _, err := bot.Send(msg); err != nil { + response, err := bot.Send(msg) + log.Trace(). + Interface("content", response). + Msg("Send Telegram Alert") + if err != nil { log.Warn(). Str("event_id", event.ID). Str("provider", "Telegram"). diff --git a/notifier/webhook.go b/notifier/webhook.go index 231e5c3..e43ce10 100644 --- a/notifier/webhook.go +++ b/notifier/webhook.go @@ -1,6 +1,8 @@ package notifier import ( + "strings" + "github.com/disgoorg/json" "github.com/rs/zerolog/log" @@ -33,8 +35,16 @@ func SendWebhook(event models.Event) { message = renderMessage("json", event) } - headers := renderHeaders(config.ConfigData.Alerts.Webhook.Headers, event) - _, err = util.HTTPPost(config.ConfigData.Alerts.Webhook.Server, config.ConfigData.Alerts.Webhook.Insecure, []byte(message), headers...) + headers := renderHTTPKV(config.ConfigData.Alerts.Webhook.Headers, event, "headers") + params := renderHTTPKV(config.ConfigData.Alerts.Webhook.Params, event, "params") + paramString := util.BuildHTTPParams(params...) + if strings.ToUpper(config.ConfigData.Alerts.Webhook.Method) == "GET" { + _, err = util.HTTPGet(config.ConfigData.Alerts.Webhook.Server, config.ConfigData.Alerts.Webhook.Insecure, paramString, headers...) + + } else { + _, err = util.HTTPPost(config.ConfigData.Alerts.Webhook.Server, config.ConfigData.Alerts.Webhook.Insecure, []byte(message), paramString, headers...) + } + if err != nil { log.Warn(). Str("event_id", event.ID). diff --git a/screenshots/ntfy.png b/screenshots/ntfy.png new file mode 100644 index 0000000..7ba41d1 Binary files /dev/null and b/screenshots/ntfy.png differ diff --git a/screenshots/pushover.png b/screenshots/pushover.png new file mode 100644 index 0000000..8720a86 Binary files /dev/null and b/screenshots/pushover.png differ diff --git a/util/httpclient.go b/util/httpclient.go index 394634c..3b931aa 100644 --- a/util/httpclient.go +++ b/util/httpclient.go @@ -3,17 +3,43 @@ package util import ( "bytes" "crypto/tls" + "encoding/json" "errors" + "fmt" "io" "net/http" + "net/url" "strconv" + "strings" "time" "github.com/rs/zerolog/log" ) +// buildParams creates an escaped param string from a slice +func BuildHTTPParams(params ...map[string]string) string { + var paramList string + if len(params) > 0 { + paramList = "?" + for _, h := range params { + for k, v := range h { + k = url.QueryEscape(k) + v = url.QueryEscape(v) + paramList = fmt.Sprintf("%s&%s=%s", paramList, k, v) + } + + } + } + + return paramList +} + // HTTPGet is a simple HTTP client function to return page body -func HTTPGet(url string, insecure bool, headers ...map[string]string) ([]byte, error) { +func HTTPGet(url string, insecure bool, params string, headers ...map[string]string) ([]byte, error) { + // Append HTTP params if any + if len(params) > 0 { + url = url + params + } // New HTTP Client client := http.Client{Timeout: 10 * time.Second} @@ -39,7 +65,20 @@ func HTTPGet(url string, insecure bool, headers ...map[string]string) ([]byte, e } } + // Remove authorization header value for logging + for i, h := range headers { + for k := range h { + if strings.ToLower(k) == "authorization" { + headers[i][k] = "--secret removed--" + } + } + } // Send HTTP GET + log.Trace(). + Str("url", url). + Interface("headers", headers). + Bool("insecure", insecure). + Msg("HTTP GET") response, err := client.Do(req) if err != nil { return nil, err @@ -52,6 +91,19 @@ func HTTPGet(url string, insecure bool, headers ...map[string]string) ([]byte, e return nil, err } + // Skip logging contents of snapshot image + if strings.Contains(url, "snapshot.jpg") { + log.Trace(). + Int64("content_length", response.ContentLength). + Int("status_code", response.StatusCode). + Msg("HTTP Response") + } else { + log.Trace(). + RawJSON("body", body). + Int("status_code", response.StatusCode). + Msg("HTTP Response") + } + if response.StatusCode != 200 { return nil, errors.New(strconv.Itoa(response.StatusCode)) } @@ -61,7 +113,12 @@ func HTTPGet(url string, insecure bool, headers ...map[string]string) ([]byte, e // HTTPPost performs an HTTP POST to the target URL // and includes auth parameters, ignoring certificates, etc -func HTTPPost(url string, insecure bool, payload []byte, headers ...map[string]string) ([]byte, error) { +func HTTPPost(url string, insecure bool, payload []byte, params string, headers ...map[string]string) ([]byte, error) { + // Append HTTP params if any + if len(params) > 0 { + url = url + params + } + // New HTTP Client client := http.Client{Timeout: 10 * time.Second} @@ -87,7 +144,32 @@ func HTTPPost(url string, insecure bool, payload []byte, headers ...map[string]s } } + // Remove authorization header value for logging + for i, h := range headers { + for k := range h { + if strings.ToLower(k) == "authorization" { + headers[i][k] = "--secret removed--" + } + } + } + // Send HTTP POST + if json.Valid(payload) { + log.Trace(). + Str("url", url). + Interface("headers", headers). + RawJSON("body", payload). + Bool("insecure", insecure). + Msg("HTTP POST") + } else { + log.Trace(). + Str("url", url). + Interface("headers", headers). + Interface("body", payload). + Bool("insecure", insecure). + Msg("HTTP POST") + } + var response *http.Response retry := 1 for retry <= 6 { @@ -124,5 +206,10 @@ func HTTPPost(url string, insecure bool, payload []byte, headers ...map[string]s return nil, err } + log.Trace(). + RawJSON("body", body). + Int("status_code", response.StatusCode). + Msg("HTTP Response") + return body, nil }