Switch to config-file, support multiple notifiers of same type

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-03-13 13:33:18 +01:00
parent 20b0969ca4
commit bd35172bb4
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
13 changed files with 341 additions and 117 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
birthday-notifier birthday-notifier
config.yaml

107
README.md
View file

@ -17,30 +17,97 @@ Hosted somewhere it's always running and configured properly and birthday notifi
```console ```console
# birthday-notifier --help # birthday-notifier --help
Usage of birthday-notifier: Usage of birthday-notifier:
--fetch-interval duration How often to fetch birthdays from CardDAV (default 1h0m0s) -c, --config string Configuration file path (default "config.yaml")
--log-level string Log level (debug, info, warn, error, fatal) (default "info") --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 --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
``` ```
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 # Specify your own template for the notification text. The default is
- **`pushover`** - Send notification via [Pushover](https://pushover.net) # shown below and whould yield something like this:
- `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 # Ava has their birthday on Wed, 13 Mar. They are turning 27.
- `PUSHOVER_SOUND` - (Optional) Specify a sound to use template: >-
- **`slack`** - Send notification through Slack(-compatible) webhook {{ .contact | getName }} has their birthday
- `SLACK_WEBHOOK` - Webhook URL (i.e. `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX` or `https://discord.com/api/webhooks/00000/XXXXX/slack`) {{ if .when | isToday -}} today {{- else -}}
- `SLACK_CHANNEL` - (Optional) Specify the channel to send to on {{ (.when | projectToNext).Format "Mon, 02 Jan" }} {{- end }}.
- `SLACK_ICON_EMOJI` - (Optional) Emoji to use as user icon {{ if gt .when.Year 1 -}}They are turning {{ .when | getAge }}.{{- end }}
- `SLACK_USERNAME` - (Optional) Overwrite the hooks username
# 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: ''
```

View file

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"time" "time"
"git.luzifer.io/luzifer/birthday-notifier/pkg/config"
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil" "git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav" "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( client, err := carddav.NewClient(
webdav.HTTPClientWithBasicAuth(http.DefaultClient, cfg.WebdavUser, cfg.WebdavPass), webdav.HTTPClientWithBasicAuth(http.DefaultClient, webdavConfig.User, webdavConfig.Pass),
cfg.WebdavBaseURL, webdavConfig.BaseURL,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("creating carddav client: %w", err) return nil, fmt.Errorf("creating carddav client: %w", err)
@ -31,7 +32,7 @@ func fetchBirthdays() (birthdays []birthdayEntry, err error) {
homeSet, err := client.FindAddressBookHomeSet( homeSet, err := client.FindAddressBookHomeSet(
context.Background(), context.Background(),
fmt.Sprintf(cfg.WebdavPrincipal, cfg.WebdavUser), fmt.Sprintf(webdavConfig.Principal, webdavConfig.User),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("getting addressbook-home-set: %w", err) return nil, fmt.Errorf("getting addressbook-home-set: %w", err)

5
go.mod
View file

@ -3,6 +3,7 @@ module git.luzifer.io/luzifer/birthday-notifier
go 1.22.0 go 1.22.0
require ( require (
github.com/Luzifer/go_helpers/v2 v2.23.0
github.com/Luzifer/rconfig/v2 v2.5.0 github.com/Luzifer/rconfig/v2 v2.5.0
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
github.com/emersion/go-webdav v0.5.0 github.com/emersion/go-webdav v0.5.0
@ -11,13 +12,13 @@ require (
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // 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/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

11
go.sum
View file

@ -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 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok=
github.com/Luzifer/rconfig/v2 v2.5.0/go.mod h1:eGWUPQeCPv/Pr/p0hjmwFgI20uqvwi/Szen69hUzGzU= 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= 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/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 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= 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.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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

82
main.go
View file

@ -6,26 +6,23 @@ import (
"sync" "sync"
"time" "time"
"git.luzifer.io/luzifer/birthday-notifier/pkg/config"
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil" "git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/Luzifer/rconfig/v2" "github.com/Luzifer/rconfig/v2"
) )
var ( var (
cfg = struct { cfg = struct {
FetchInterval time.Duration `flag:"fetch-interval" default:"1h" description:"How often to fetch birthdays from CardDAV"` 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)"` 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"` VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{} }{}
@ -61,33 +58,40 @@ func main() {
os.Exit(0) os.Exit(0)
} }
var notifiers []notifier.Notifier configFile, err := config.LoadFromFile(cfg.Config)
for _, nv := range cfg.NotifyVia { if err != nil {
notify := getNotifierByName(nv) logrus.WithError(err).Fatal("loading configuration file")
if notify == nil {
logrus.Fatal("unknown notifier specified")
}
notifiers = append(notifiers, notify)
} }
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") logrus.WithError(err).Fatal("initially fetching birthdays")
} }
crontab := cron.New() crontab := cron.New()
// Periodically update birthdays // 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") logrus.WithError(err).Fatal("adding update-cron")
} }
// Send notifications at midnight // 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.WithError(err).Fatal("adding update-cron")
} }
logrus.WithFields(logrus.Fields{ logrus.WithFields(logrus.Fields{
"advance": cfg.NotifyDaysInAdvance, "advance": configFile.NotifyDaysInAdvance,
"version": version, "version": version,
}).Info("birthday-notifier started") }).Info("birthday-notifier started")
crontab.Start() crontab.Start()
@ -97,36 +101,45 @@ func main() {
} }
} }
func cronFetchBirthdays() { func cronFetchBirthdays(webdavConfig config.WebdavConfig) func() {
return func() {
birthdaysLock.Lock() birthdaysLock.Lock()
defer birthdaysLock.Unlock() defer birthdaysLock.Unlock()
var err error var err error
if birthdays, err = fetchBirthdays(); err != nil { if birthdays, err = fetchBirthdays(webdavConfig); err != nil {
logrus.WithError(err).Error("updating birthdays") logrus.WithError(err).Error("updating birthdays")
} }
} }
}
func cronSendNotifications(notifiers []notifier.Notifier) func() { func cronSendNotifications(configFile config.File) func() {
return func() { return func() {
birthdaysLock.Lock() birthdaysLock.Lock()
defer birthdaysLock.Unlock() defer birthdaysLock.Unlock()
for _, b := range birthdays { 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)) { if !dateutil.IsToday(notifyDate(dateutil.ProjectToNextBirthday(b.birthday), advanceDays)) {
continue continue
} }
for i := range notifiers { for i := range configFile.Notifiers {
go func(n notifier.Notifier, contact vcard.Card, when time.Time) { notifyInstance := getNotifierByName(configFile.Notifiers[i].Type)
if err := n.SendNotification(contact, when); err != nil {
go func(
n notifier.Notifier,
settings *fieldcollection.FieldCollection,
contact vcard.Card,
when time.Time,
) {
if err := n.SendNotification(settings, contact, when); err != nil {
logrus. logrus.
WithError(err). WithError(err).
WithField("name", contact.Get(vcard.FieldFormattedName).Value). WithField("name", contact.Get(vcard.FieldFormattedName).Value).
Error("sending notification") 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 { 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) 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
}

90
pkg/config/config.go Normal file
View file

@ -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,
},
}
}

View file

@ -5,7 +5,6 @@ package formatter
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"regexp" "regexp"
"strings" "strings"
"text/template" "text/template"
@ -18,7 +17,9 @@ import (
const timeDay = 24 * time.Hour const timeDay = 24 * time.Hour
var ( 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 }}. {{ .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 }} {{ if gt .when.Year 1 -}}They are turning {{ .when | getAge }}.{{- end }}
`, "\n", " ")), " ") `, "\n", " ")), " ")
@ -26,24 +27,6 @@ var (
notifyTpl *template.Template 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 // FormatNotificationText takes the notification template and renders
// the contact / birthday date into a text to submit in the notification // the contact / birthday date into a text to submit in the notification
func FormatNotificationText(contact vcard.Card, when time.Time) (text string, err error) { 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 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 { func getAge(t time.Time) int {
return dateutil.ProjectToNextBirthday(t).Year() - t.Year() return dateutil.ProjectToNextBirthday(t).Year() - t.Year()
} }

View file

@ -20,6 +20,8 @@ func getTestVCard(t *testing.T, content string) vcard.Card {
} }
func TestFormatNotificationText(t *testing.T) { func TestFormatNotificationText(t *testing.T) {
require.NoError(t, SetTemplate(DefaultTemplate))
card := getTestVCard(t, `BEGIN:VCARD card := getTestVCard(t, `BEGIN:VCARD
VERSION:4.0 VERSION:4.0
N:Bloggs;Joe;;; N:Bloggs;Joe;;;

View file

@ -7,6 +7,7 @@ import (
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -19,7 +20,7 @@ type (
var _ notifier.Notifier = Notifier{} var _ notifier.Notifier = Notifier{}
// SendNotification implements the Notifier interface // 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 { if contact.Name() == nil {
return fmt.Errorf("contact has no name") 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) logrus.WithField("name", contact.Name().GivenName).Info(text)
return nil return nil
} }
// ValidateSettings implements the Notifier interface
func (Notifier) ValidateSettings(*fieldcollection.FieldCollection) error {
// We don't take settings so everything is fine
return nil
}

View file

@ -4,6 +4,7 @@ package notifier
import ( import (
"time" "time"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
) )
@ -13,7 +14,13 @@ type (
// SendNotification will be called with the contact and the // SendNotification will be called with the contact and the
// time when the birthday actually is. The method is therefore // time when the birthday actually is. The method is therefore
// also called when a notification in advance is configured and // also called when a notification in advance is configured and
// needs to properly format the notification for that. // needs to properly format the notification for that. The settings
SendNotification(contact vcard.Card, when time.Time) error // 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
} }
) )

View file

@ -4,11 +4,11 @@ package pushover
import ( import (
"fmt" "fmt"
"os"
"time" "time"
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/gregdel/pushover" "github.com/gregdel/pushover"
) )
@ -18,24 +18,16 @@ type (
Notifier struct{} Notifier struct{}
) )
var _ notifier.Notifier = Notifier{}
// SendNotification implements the Notifier interface
func (Notifier) SendNotification(contact vcard.Card, when time.Time) error {
if contact.Name() == nil {
return fmt.Errorf("contact has no name")
}
var ( var (
apiToken = os.Getenv("PUSHOVER_API_TOKEN") ptrStrEmpty = func(v string) *string { return &v }("")
userKey = os.Getenv("PUSHOVER_USER_KEY")
_ notifier.Notifier = Notifier{}
) )
if apiToken == "" { // SendNotification implements the Notifier interface
return fmt.Errorf("missing PUSHOVER_API_TOKEN env variable") func (Notifier) SendNotification(settings *fieldcollection.FieldCollection, contact vcard.Card, when time.Time) error {
} if contact.Name() == nil {
if userKey == "" { return fmt.Errorf("contact has no name")
return fmt.Errorf("missing PUSHOVER_USER_KEY env variable")
} }
text, err := formatter.FormatNotificationText(contact, when) text, err := formatter.FormatNotificationText(contact, when)
@ -46,13 +38,26 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error {
message := &pushover.Message{ message := &pushover.Message{
Message: text, Message: text,
Title: formatter.FormatNotificationTitle(contact), Title: formatter.FormatNotificationTitle(contact),
Sound: os.Getenv("PUSHOVER_SOUND"), Sound: settings.MustString("sound", ptrStrEmpty),
} }
if _, err = pushover.New(apiToken). if _, err = pushover.New(settings.MustString("apiToken", nil)).
SendMessage(message, pushover.NewRecipient(userKey)); err != nil { SendMessage(message, pushover.NewRecipient(settings.MustString("userKey", nil))); err != nil {
return fmt.Errorf("sending notification: %w", err) return fmt.Errorf("sending notification: %w", err)
} }
return nil 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
}

View file

@ -8,11 +8,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"time" "time"
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter" "git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier" "git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/emersion/go-vcard" "github.com/emersion/go-vcard"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -24,20 +24,18 @@ type (
Notifier struct{} Notifier struct{}
) )
var _ notifier.Notifier = Notifier{} var (
ptrStrEmpty = func(v string) *string { return &v }("")
_ notifier.Notifier = Notifier{}
)
// SendNotification implements the Notifier interface // 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 { if contact.Name() == nil {
return fmt.Errorf("contact has no name") 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) text, err := formatter.FormatNotificationText(contact, when)
if err != nil { if err != nil {
return fmt.Errorf("rendering text: %w", err) 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"` Text string `json:"text"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
}{ }{
Channel: os.Getenv("SLACK_CHANNEL"), Channel: settings.MustString("channel", ptrStrEmpty),
IconEmoji: os.Getenv("SLACK_ICON_EMOJI"), IconEmoji: settings.MustString("iconEmoji", ptrStrEmpty),
Text: text, Text: text,
Username: os.Getenv("SLACK_USERNAME"), Username: settings.MustString("username", ptrStrEmpty),
}); err != nil { }); err != nil {
return fmt.Errorf("encoding hook payload: %w", err) 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) ctx, cancel := context.WithTimeout(context.Background(), webhookPostTimeout)
defer cancel() 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 { if err != nil {
return fmt.Errorf("creating request: %w", err) return fmt.Errorf("creating request: %w", err)
} }
@ -83,3 +81,12 @@ func (Notifier) SendNotification(contact vcard.Card, when time.Time) error {
return nil 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
}