From bd35172bb4953d60ce27eabc1b99a3450c3ef0ea Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Wed, 13 Mar 2024 13:33:18 +0100 Subject: [PATCH] Switch to config-file, support multiple notifiers of same type Signed-off-by: Knut Ahlers --- .gitignore | 1 + README.md | 111 ++++++++++++++++++++++++------ caldav.go | 9 +-- go.mod | 5 +- go.sum | 11 ++- main.go | 94 ++++++++++++++++--------- pkg/config/config.go | 90 ++++++++++++++++++++++++ pkg/formatter/formatter.go | 41 +++++------ pkg/formatter/formatter_test.go | 2 + pkg/notifier/log/log.go | 9 ++- pkg/notifier/notifier.go | 11 ++- pkg/notifier/pushover/pushover.go | 41 ++++++----- pkg/notifier/slack/slack.go | 33 +++++---- 13 files changed, 341 insertions(+), 117 deletions(-) create mode 100644 pkg/config/config.go diff --git a/.gitignore b/.gitignore index d8119a8..2f0c2f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ birthday-notifier +config.yaml diff --git a/README.md b/README.md index 012f82d..ef1bd2d 100644 --- a/README.md +++ b/README.md @@ -17,30 +17,97 @@ Hosted somewhere it's always running and configured properly and birthday notifi ```console # birthday-notifier --help Usage of birthday-notifier: - --fetch-interval duration How often to fetch birthdays from CardDAV (default 1h0m0s) - --log-level string Log level (debug, info, warn, error, fatal) (default "info") - --notify-days-in-advance ints Send notification X days before birthday (default [1]) - --notify-via strings How to send the notification (log, pushover, slack) (default [log]) - --version Prints current version and exits - --webdav-base-url string Webdav server to connect to - --webdav-pass string Password for the Webdav user - --webdav-principal string Principal format to fetch the addressbooks for (%s will be replaced with the webdav-user) (default "principals/users/%s") - --webdav-user string Username for Webdav login + -c, --config string Configuration file path (default "config.yaml") + --log-level string Log level (debug, info, warn, error, fatal) (default "info") + --version Prints current version and exits ``` -For Nextcloud leave the principal format the default, for other systems you might need to adjust it. +## Configuration -To adjust the notification text see the template in [`pkg/formatter/formatter.go`](./pkg/formatter/formatter.go) and provide your own as `NOTIFICATION_TEMPLATE` environment variable. +```yaml +# Specify days before the actual birthday to send advance notifications +# i.e. to buy gifts or something. Default is to send only on the actual +# birthday itself. +notifyDaysInAdvance: [ 1 ] -### Notifier configuration +# Configure how to notify you when there is a birthday pending / today. +# Each entry consists of a type and the settings for that kind of +# notifier. For settings and available types see below. +notifiers: + - type: slack + settings: + webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX -- **`log`** - Just sends the notification to the console logs, no configuration available -- **`pushover`** - Send notification via [Pushover](https://pushover.net) - - `PUSHOVER_API_TOKEN` - Token for the App you've created in the Pushover Dashboard - - `PUSHOVER_USER_KEY` - Token for the User to send the notification to - - `PUSHOVER_SOUND` - (Optional) Specify a sound to use -- **`slack`** - Send notification through Slack(-compatible) webhook - - `SLACK_WEBHOOK` - Webhook URL (i.e. `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX` or `https://discord.com/api/webhooks/00000/XXXXX/slack`) - - `SLACK_CHANNEL` - (Optional) Specify the channel to send to - - `SLACK_ICON_EMOJI` - (Optional) Emoji to use as user icon - - `SLACK_USERNAME` - (Optional) Overwrite the hooks username +# Specify your own template for the notification text. The default is +# shown below and whould yield something like this: +# +# Ava has their birthday on Wed, 13 Mar. They are turning 27. +template: >- + {{ .contact | getName }} has their birthday + {{ if .when | isToday -}} today {{- else -}} + on {{ (.when | projectToNext).Format "Mon, 02 Jan" }} {{- end }}. + {{ if gt .when.Year 1 -}}They are turning {{ .when | getAge }}.{{- end }} + +# Configure how to connect to the CardDAV addressbooks inside the +# webdav server +webdav: + # Base-URL for the webdav server (example for Nextcloud) + baseURL: https://my-nextcloud.example.com/remote.php/dav/ + # How often to fetch new birthdays (default: 1h) + fetchInterval: 1h + # Password for the user + pass: 'my super secret password' + # Principal format for the webdav server (default as below is valid + # for Nextcloud instances): `%s` will be replaced with the value of + # the user field below. + principal: 'principals/users/%s' + # Username for the login to the webdav server + user: 'my.username' +``` + +### Notifiers + +#### `log` + +Just sends the notification to the console logs + +```yaml +notifiers: + - type: log + # No settings for this one +``` + +#### `pushover` + +Send notification via [Pushover](https://pushover.net) + +```yaml +notifiers: + - type: pushover + settings: + # Token for the App you've created in the Pushover Dashboard + apiToken: '...' + # Token for the User to send the notification to + userKey: '...' + # (Optional) Specify a sound to use + sound: '' +``` + +#### `slack` + +Send notification through Slack(-compatible) webhook + +```yaml +notifiers: + - type: slack + settings: + # Webhook URL (i.e. `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX` + # or `https://discord.com/api/webhooks/00000/XXXXX/slack`) + webhook: 'https://...' + # (Optional) Specify the channel to send to + channel: '' + # (Optional) Emoji to use as user icon + iconEmoji: '' + # (Optional) Overwrite the hooks username\ + username: '' +``` diff --git a/caldav.go b/caldav.go index e254b1d..ad8289a 100644 --- a/caldav.go +++ b/caldav.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "git.luzifer.io/luzifer/birthday-notifier/pkg/config" "git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil" "github.com/emersion/go-vcard" "github.com/emersion/go-webdav" @@ -20,10 +21,10 @@ type ( } ) -func fetchBirthdays() (birthdays []birthdayEntry, err error) { +func fetchBirthdays(webdavConfig config.WebdavConfig) (birthdays []birthdayEntry, err error) { client, err := carddav.NewClient( - webdav.HTTPClientWithBasicAuth(http.DefaultClient, cfg.WebdavUser, cfg.WebdavPass), - cfg.WebdavBaseURL, + webdav.HTTPClientWithBasicAuth(http.DefaultClient, webdavConfig.User, webdavConfig.Pass), + webdavConfig.BaseURL, ) if err != nil { return nil, fmt.Errorf("creating carddav client: %w", err) @@ -31,7 +32,7 @@ func fetchBirthdays() (birthdays []birthdayEntry, err error) { homeSet, err := client.FindAddressBookHomeSet( context.Background(), - fmt.Sprintf(cfg.WebdavPrincipal, cfg.WebdavUser), + fmt.Sprintf(webdavConfig.Principal, webdavConfig.User), ) if err != nil { return nil, fmt.Errorf("getting addressbook-home-set: %w", err) diff --git a/go.mod b/go.mod index 7c79338..40893c9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.luzifer.io/luzifer/birthday-notifier go 1.22.0 require ( + github.com/Luzifer/go_helpers/v2 v2.23.0 github.com/Luzifer/rconfig/v2 v2.5.0 github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 github.com/emersion/go-webdav v0.5.0 @@ -11,13 +12,13 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.4 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/sys v0.18.0 // indirect gopkg.in/validator.v2 v2.0.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c8968f0..3cf38f4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Luzifer/go_helpers/v2 v2.23.0 h1:VowDwOCl6nOt+GVqKUX/do6a94pEeqNTRHb29MsoGX4= +github.com/Luzifer/go_helpers/v2 v2.23.0/go.mod h1:BSGkJ/dxqs7AxsfZt8zjJb4R6YB5dONS+/ad7foLUrk= github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok= github.com/Luzifer/rconfig/v2 v2.5.0/go.mod h1:eGWUPQeCPv/Pr/p0hjmwFgI20uqvwi/Szen69hUzGzU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -12,8 +14,8 @@ github.com/gregdel/pushover v1.3.0 h1:CewbxqsThoN/1imgwkDKFkRkltaQMoyBV0K9IquQLt github.com/gregdel/pushover v1.3.0/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -30,13 +32,16 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 6e6ab00..1302776 100644 --- a/main.go +++ b/main.go @@ -6,27 +6,24 @@ import ( "sync" "time" + "git.luzifer.io/luzifer/birthday-notifier/pkg/config" "git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil" + "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" "github.com/emersion/go-vcard" "github.com/pkg/errors" "github.com/robfig/cron/v3" "github.com/sirupsen/logrus" + "github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/Luzifer/rconfig/v2" ) var ( cfg = struct { - FetchInterval time.Duration `flag:"fetch-interval" default:"1h" description:"How often to fetch birthdays from CardDAV"` - LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` - NotifyDaysInAdvance []int `flag:"notify-days-in-advance" default:"1" description:"Send notification X days before birthday"` - NotifyVia []string `flag:"notify-via" default:"log" description:"How to send the notification (log, pushover, slack)"` - WebdavBaseURL string `flag:"webdav-base-url" default:"" description:"Webdav server to connect to"` - WebdavPass string `flag:"webdav-pass" default:"" description:"Password for the Webdav user"` - WebdavPrincipal string `flag:"webdav-principal" default:"principals/users/%s" description:"Principal format to fetch the addressbooks for (%s will be replaced with the webdav-user)"` - WebdavUser string `flag:"webdav-user" default:"" description:"Username for Webdav login"` - VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + Config string `flag:"config,c" default:"config.yaml" description:"Configuration file path"` + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` }{} birthdays []birthdayEntry @@ -61,33 +58,40 @@ func main() { os.Exit(0) } - var notifiers []notifier.Notifier - for _, nv := range cfg.NotifyVia { - notify := getNotifierByName(nv) - if notify == nil { - logrus.Fatal("unknown notifier specified") - } - notifiers = append(notifiers, notify) + configFile, err := config.LoadFromFile(cfg.Config) + if err != nil { + logrus.WithError(err).Fatal("loading configuration file") } - if birthdays, err = fetchBirthdays(); err != nil { + if err = validateNotifierConfigs(configFile); err != nil { + logrus.WithError(err).Fatal("validating configuration") + } + + if err = formatter.SetTemplate(configFile.Template); err != nil { + logrus.WithError(err).Fatal("setting template") + } + + if birthdays, err = fetchBirthdays(configFile.Webdav); err != nil { logrus.WithError(err).Fatal("initially fetching birthdays") } crontab := cron.New() // Periodically update birthdays - if _, err = crontab.AddFunc(fmt.Sprintf("@every %s", cfg.FetchInterval), cronFetchBirthdays); err != nil { + if _, err = crontab.AddFunc( + fmt.Sprintf("@every %s", configFile.Webdav.FetchInterval), + cronFetchBirthdays(configFile.Webdav), + ); err != nil { logrus.WithError(err).Fatal("adding update-cron") } // Send notifications at midnight - if _, err = crontab.AddFunc("@midnight", cronSendNotifications(notifiers)); err != nil { + if _, err = crontab.AddFunc("@midnight", cronSendNotifications(configFile)); err != nil { logrus.WithError(err).Fatal("adding update-cron") } logrus.WithFields(logrus.Fields{ - "advance": cfg.NotifyDaysInAdvance, + "advance": configFile.NotifyDaysInAdvance, "version": version, }).Info("birthday-notifier started") crontab.Start() @@ -97,36 +101,45 @@ func main() { } } -func cronFetchBirthdays() { - birthdaysLock.Lock() - defer birthdaysLock.Unlock() +func cronFetchBirthdays(webdavConfig config.WebdavConfig) func() { + return func() { + birthdaysLock.Lock() + defer birthdaysLock.Unlock() - var err error - if birthdays, err = fetchBirthdays(); err != nil { - logrus.WithError(err).Error("updating birthdays") + var err error + if birthdays, err = fetchBirthdays(webdavConfig); err != nil { + logrus.WithError(err).Error("updating birthdays") + } } } -func cronSendNotifications(notifiers []notifier.Notifier) func() { +func cronSendNotifications(configFile config.File) func() { return func() { birthdaysLock.Lock() defer birthdaysLock.Unlock() for _, b := range birthdays { - for _, advanceDays := range append(cfg.NotifyDaysInAdvance, 0) { + for _, advanceDays := range append(configFile.NotifyDaysInAdvance, 0) { if !dateutil.IsToday(notifyDate(dateutil.ProjectToNextBirthday(b.birthday), advanceDays)) { continue } - for i := range notifiers { - go func(n notifier.Notifier, contact vcard.Card, when time.Time) { - if err := n.SendNotification(contact, when); err != nil { + for i := range configFile.Notifiers { + notifyInstance := getNotifierByName(configFile.Notifiers[i].Type) + + go func( + n notifier.Notifier, + settings *fieldcollection.FieldCollection, + contact vcard.Card, + when time.Time, + ) { + if err := n.SendNotification(settings, contact, when); err != nil { logrus. WithError(err). WithField("name", contact.Get(vcard.FieldFormattedName).Value). Error("sending notification") } - }(notifiers[i], b.contact, b.birthday) + }(notifyInstance, configFile.Notifiers[i].Settings, b.contact, b.birthday) } } } @@ -136,3 +149,20 @@ func cronSendNotifications(notifiers []notifier.Notifier) func() { func notifyDate(t time.Time, daysInAdvance int) time.Time { return time.Date(t.Year(), t.Month(), t.Day()-daysInAdvance, 0, 0, 0, 0, time.Local) } + +func validateNotifierConfigs(configFile config.File) (err error) { + for i := range configFile.Notifiers { + notifierCfg := configFile.Notifiers[i] + + n := getNotifierByName(notifierCfg.Type) + if n == nil { + return fmt.Errorf("notifier %q does not exist", notifierCfg.Type) + } + + if err = n.ValidateSettings(notifierCfg.Settings); err != nil { + return fmt.Errorf("settings for %q are invalid: %w", notifierCfg.Type, err) + } + } + + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..ec3ef1d --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,90 @@ +// Package config contains parser and structure for the configuration +// of the tool +package config + +import ( + "fmt" + "io" + "os" + "time" + + "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" + "github.com/Luzifer/go_helpers/v2/fieldcollection" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" +) + +// WebdavPrincipalNextcloud is the principal default used for config +// files on parse and represents the principal format used by Nextcloud +const WebdavPrincipalNextcloud = "principals/users/%s" + +type ( + // File contains the structure of the YAML configuration file + File struct { + NotifyDaysInAdvance []int `yaml:"notifyDaysInAdvance"` + Notifiers []NotifierConfig `yaml:"notifiers"` + + Template string `yaml:"template"` + + Webdav WebdavConfig `yaml:"webdav"` + } + + // NotifierConfig contains the type of the notifier and the settings + // for it required to execute + NotifierConfig struct { + Type string `yaml:"type"` + Settings *fieldcollection.FieldCollection `yaml:"settings"` + } + + // WebdavConfig defines how to interact with the Webdav server + WebdavConfig struct { + BaseURL string `yaml:"baseURL"` + FetchInterval time.Duration `yaml:"fetchInterval"` + Pass string `yaml:"pass"` + Principal string `yaml:"principal"` + User string `yaml:"user"` + } +) + +// Load parses the given reader over a default configuration replacing +// the fields specified in the reader +func Load(r io.Reader) (f File, err error) { + f = defaultConfig() + dec := yaml.NewDecoder(r) + + dec.KnownFields(true) + if err = dec.Decode(&f); err != nil { + return f, fmt.Errorf("decoding yaml: %w", err) + } + + return f, nil +} + +// LoadFromFile is a convenience wrapper around Load to read the config +// from file system +func LoadFromFile(filePath string) (f File, err error) { + inFile, err := os.Open(filePath) //#nosec G304 -- Intended to load a given path + if err != nil { + return f, fmt.Errorf("opening file: %w", err) + } + defer func() { + if err := inFile.Close(); err != nil { + logrus.WithError(err).Error("closing config file (leaked fd)") + } + }() + + return Load(inFile) +} + +func defaultConfig() File { + return File{ + NotifyDaysInAdvance: nil, + + Template: formatter.DefaultTemplate, + + Webdav: WebdavConfig{ + FetchInterval: time.Hour, + Principal: WebdavPrincipalNextcloud, + }, + } +} diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index e731b8a..caf49bc 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -5,7 +5,6 @@ package formatter import ( "bytes" "fmt" - "os" "regexp" "strings" "text/template" @@ -18,7 +17,9 @@ import ( const timeDay = 24 * time.Hour var ( - defaultTemplate = regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(strings.ReplaceAll(` + // DefaultTemplate contains the template used in testing and as a + // default in the config package + DefaultTemplate = regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(strings.ReplaceAll(` {{ .contact | getName }} has their birthday {{ if .when | isToday -}} today {{- else -}} on {{ (.when | projectToNext).Format "Mon, 02 Jan" }} {{- end }}. {{ if gt .when.Year 1 -}}They are turning {{ .when | getAge }}.{{- end }} `, "\n", " ")), " ") @@ -26,24 +27,6 @@ var ( notifyTpl *template.Template ) -func init() { - rawTpl := defaultTemplate - if tpl := os.Getenv("NOTIFICATION_TEMPLATE"); tpl != "" { - rawTpl = tpl - } - - var err error - notifyTpl, err = template.New("notification").Funcs(template.FuncMap{ - "getAge": getAge, - "getName": getContactName, - "isToday": dateutil.IsToday, - "projectToNext": dateutil.ProjectToNextBirthday, - }).Parse(rawTpl) - if err != nil { - panic(fmt.Errorf("parsing notification template: %w", err)) - } -} - // FormatNotificationText takes the notification template and renders // the contact / birthday date into a text to submit in the notification func FormatNotificationText(contact vcard.Card, when time.Time) (text string, err error) { @@ -75,6 +58,24 @@ func FormatNotificationTitle(contact vcard.Card) (title string) { return title } +// SetTemplate initializes the template to use in the +// FormatNotificationText function. This MUST be called before first +// use of the FormatNotificationText function. +func SetTemplate(rawTpl string) error { + var err error + notifyTpl, err = template.New("notification").Funcs(template.FuncMap{ + "getAge": getAge, + "getName": getContactName, + "isToday": dateutil.IsToday, + "projectToNext": dateutil.ProjectToNextBirthday, + }).Parse(rawTpl) + if err != nil { + return fmt.Errorf("parsing notification template: %w", err) + } + + return nil +} + func getAge(t time.Time) int { return dateutil.ProjectToNextBirthday(t).Year() - t.Year() } diff --git a/pkg/formatter/formatter_test.go b/pkg/formatter/formatter_test.go index 13f6676..5664df1 100644 --- a/pkg/formatter/formatter_test.go +++ b/pkg/formatter/formatter_test.go @@ -20,6 +20,8 @@ func getTestVCard(t *testing.T, content string) vcard.Card { } func TestFormatNotificationText(t *testing.T) { + require.NoError(t, SetTemplate(DefaultTemplate)) + card := getTestVCard(t, `BEGIN:VCARD VERSION:4.0 N:Bloggs;Joe;;; diff --git a/pkg/notifier/log/log.go b/pkg/notifier/log/log.go index ecd0269..12fd948 100644 --- a/pkg/notifier/log/log.go +++ b/pkg/notifier/log/log.go @@ -7,6 +7,7 @@ import ( "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" + "github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/emersion/go-vcard" "github.com/sirupsen/logrus" ) @@ -19,7 +20,7 @@ type ( var _ notifier.Notifier = Notifier{} // SendNotification implements the Notifier interface -func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { +func (Notifier) SendNotification(_ *fieldcollection.FieldCollection, contact vcard.Card, when time.Time) error { if contact.Name() == nil { return fmt.Errorf("contact has no name") } @@ -32,3 +33,9 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { logrus.WithField("name", contact.Name().GivenName).Info(text) return nil } + +// ValidateSettings implements the Notifier interface +func (Notifier) ValidateSettings(*fieldcollection.FieldCollection) error { + // We don't take settings so everything is fine + return nil +} diff --git a/pkg/notifier/notifier.go b/pkg/notifier/notifier.go index cc32268..4da2a09 100644 --- a/pkg/notifier/notifier.go +++ b/pkg/notifier/notifier.go @@ -4,6 +4,7 @@ package notifier import ( "time" + "github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/emersion/go-vcard" ) @@ -13,7 +14,13 @@ type ( // SendNotification will be called with the contact and the // time when the birthday actually is. The method is therefore // also called when a notification in advance is configured and - // needs to properly format the notification for that. - SendNotification(contact vcard.Card, when time.Time) error + // needs to properly format the notification for that. The settings + // passed through this call MUST NOT be stored. + SendNotification(settings *fieldcollection.FieldCollection, contact vcard.Card, when time.Time) error + + // ValidateSettings is called after configuration load to validate + // the settings are suitable for the notifier and do not yield + // surprising errors when doing the real notifications + ValidateSettings(settings *fieldcollection.FieldCollection) error } ) diff --git a/pkg/notifier/pushover/pushover.go b/pkg/notifier/pushover/pushover.go index 02752fd..4df537e 100644 --- a/pkg/notifier/pushover/pushover.go +++ b/pkg/notifier/pushover/pushover.go @@ -4,11 +4,11 @@ package pushover import ( "fmt" - "os" "time" "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" + "github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/emersion/go-vcard" "github.com/gregdel/pushover" ) @@ -18,26 +18,18 @@ type ( Notifier struct{} ) -var _ notifier.Notifier = Notifier{} +var ( + ptrStrEmpty = func(v string) *string { return &v }("") + + _ notifier.Notifier = Notifier{} +) // SendNotification implements the Notifier interface -func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { +func (Notifier) SendNotification(settings *fieldcollection.FieldCollection, contact vcard.Card, when time.Time) error { if contact.Name() == nil { return fmt.Errorf("contact has no name") } - var ( - apiToken = os.Getenv("PUSHOVER_API_TOKEN") - userKey = os.Getenv("PUSHOVER_USER_KEY") - ) - - if apiToken == "" { - return fmt.Errorf("missing PUSHOVER_API_TOKEN env variable") - } - if userKey == "" { - return fmt.Errorf("missing PUSHOVER_USER_KEY env variable") - } - text, err := formatter.FormatNotificationText(contact, when) if err != nil { return fmt.Errorf("rendering text: %w", err) @@ -46,13 +38,26 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { message := &pushover.Message{ Message: text, Title: formatter.FormatNotificationTitle(contact), - Sound: os.Getenv("PUSHOVER_SOUND"), + Sound: settings.MustString("sound", ptrStrEmpty), } - if _, err = pushover.New(apiToken). - SendMessage(message, pushover.NewRecipient(userKey)); err != nil { + if _, err = pushover.New(settings.MustString("apiToken", nil)). + SendMessage(message, pushover.NewRecipient(settings.MustString("userKey", nil))); err != nil { return fmt.Errorf("sending notification: %w", err) } return nil } + +// ValidateSettings implements the Notifier interface +func (Notifier) ValidateSettings(settings *fieldcollection.FieldCollection) (err error) { + if v, err := settings.String("apiToken"); err != nil || v == "" { + return fmt.Errorf("apiToken is expected to be non-empty string") + } + + if v, err := settings.String("userKey"); err != nil || v == "" { + return fmt.Errorf("userKey is expected to be non-empty string") + } + + return nil +} diff --git a/pkg/notifier/slack/slack.go b/pkg/notifier/slack/slack.go index 04ea3d4..512b285 100644 --- a/pkg/notifier/slack/slack.go +++ b/pkg/notifier/slack/slack.go @@ -8,11 +8,11 @@ import ( "encoding/json" "fmt" "net/http" - "os" "time" "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" + "github.com/Luzifer/go_helpers/v2/fieldcollection" "github.com/emersion/go-vcard" "github.com/sirupsen/logrus" ) @@ -24,20 +24,18 @@ type ( Notifier struct{} ) -var _ notifier.Notifier = Notifier{} +var ( + ptrStrEmpty = func(v string) *string { return &v }("") + + _ notifier.Notifier = Notifier{} +) // SendNotification implements the Notifier interface -func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { +func (Notifier) SendNotification(settings *fieldcollection.FieldCollection, contact vcard.Card, when time.Time) error { if contact.Name() == nil { return fmt.Errorf("contact has no name") } - webhookURL := os.Getenv("SLACK_WEBHOOK") - - if webhookURL == "" { - return fmt.Errorf("missing SLACK_WEBHOOK env variable") - } - text, err := formatter.FormatNotificationText(contact, when) if err != nil { return fmt.Errorf("rendering text: %w", err) @@ -50,10 +48,10 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { Text string `json:"text"` Username string `json:"username,omitempty"` }{ - Channel: os.Getenv("SLACK_CHANNEL"), - IconEmoji: os.Getenv("SLACK_ICON_EMOJI"), + Channel: settings.MustString("channel", ptrStrEmpty), + IconEmoji: settings.MustString("iconEmoji", ptrStrEmpty), Text: text, - Username: os.Getenv("SLACK_USERNAME"), + Username: settings.MustString("username", ptrStrEmpty), }); err != nil { return fmt.Errorf("encoding hook payload: %w", err) } @@ -61,7 +59,7 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { ctx, cancel := context.WithTimeout(context.Background(), webhookPostTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, settings.MustString("webhook", nil), payload) if err != nil { return fmt.Errorf("creating request: %w", err) } @@ -83,3 +81,12 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error { return nil } + +// ValidateSettings implements the Notifier interface +func (Notifier) ValidateSettings(settings *fieldcollection.FieldCollection) (err error) { + if v, err := settings.String("webhook"); err != nil || v == "" { + return fmt.Errorf("webhook is expected to be non-empty string") + } + + return nil +}