Initial version
This commit is contained in:
commit
db4c17256c
18 changed files with 989 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
birthday-notifier
|
29
Dockerfile
Normal file
29
Dockerfile
Normal file
|
@ -0,0 +1,29 @@
|
|||
FROM golang:alpine as builder
|
||||
|
||||
COPY . /go/src/birthday-notifier
|
||||
WORKDIR /go/src/birthday-notifier
|
||||
|
||||
RUN set -ex \
|
||||
&& apk add --update git \
|
||||
&& go install \
|
||||
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
|
||||
-mod=readonly \
|
||||
-modcacherw \
|
||||
-trimpath
|
||||
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
LABEL maintainer "Knut Ahlers <knut@ahlers.me>"
|
||||
|
||||
RUN set -ex \
|
||||
&& apk --no-cache add \
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
COPY --from=builder /go/bin/birthday-notifier /usr/local/bin/birthday-notifier
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/birthday-notifier"]
|
||||
CMD ["--"]
|
||||
|
||||
# vim: set ft=Dockerfile:
|
202
LICENSE
Normal file
202
LICENSE
Normal file
|
@ -0,0 +1,202 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2024- Knut Ahlers <knut@ahlers.me>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
41
README.md
Normal file
41
README.md
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Luzifer / birthday-notifier
|
||||
|
||||
Previously I used an app on my phone to notify me on upcoming birthdays: One day in advance and on the same day. Then that app decided to add a skew of one day and notify me too late…
|
||||
|
||||
And that's why there is now a server based solution to notifying my of upcoming birthdays.
|
||||
|
||||
Features:
|
||||
|
||||
- Sync contacts using CardDAV
|
||||
- Extract birthdays from the contacts
|
||||
- Send notifications
|
||||
|
||||
Hosted somewhere it's always running and configured properly and birthday notifications are coming in properly.
|
||||
|
||||
## Usage
|
||||
|
||||
```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 string How to send the notification (one of: log, pushover) (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
|
||||
```
|
||||
|
||||
For Nextcloud leave the principal format the default, for other systems you might need to adjust it.
|
||||
|
||||
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.
|
||||
|
||||
### Notifier configuration
|
||||
|
||||
- **`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
|
72
caldav.go
Normal file
72
caldav.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/carddav"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type (
|
||||
birthdayEntry struct {
|
||||
contact vcard.Card
|
||||
birthday time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func fetchBirthdays() (birthdays []birthdayEntry, err error) {
|
||||
client, err := carddav.NewClient(
|
||||
webdav.HTTPClientWithBasicAuth(http.DefaultClient, cfg.WebdavUser, cfg.WebdavPass),
|
||||
cfg.WebdavBaseURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating carddav client: %w", err)
|
||||
}
|
||||
|
||||
homeSet, err := client.FindAddressBookHomeSet(
|
||||
context.Background(),
|
||||
fmt.Sprintf(cfg.WebdavPrincipal, cfg.WebdavUser),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting addressbook-home-set: %w", err)
|
||||
}
|
||||
|
||||
books, err := client.FindAddressBooks(context.Background(), homeSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting addressbooks: %w", err)
|
||||
}
|
||||
|
||||
for _, book := range books {
|
||||
contacts, err := client.QueryAddressBook(context.Background(), book.Path, &carddav.AddressBookQuery{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting contacts: %w", err)
|
||||
}
|
||||
|
||||
for _, address := range contacts {
|
||||
bday := address.Card.Get(vcard.FieldBirthday)
|
||||
if bday == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bdayDate, err := dateutil.Parse(bday)
|
||||
if err != nil {
|
||||
logrus.WithField("date", bday).WithError(err).Error("parsing birthday")
|
||||
continue
|
||||
}
|
||||
|
||||
birthdays = append(birthdays, birthdayEntry{
|
||||
contact: address.Card,
|
||||
birthday: bdayDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof("fetched %d birthdays from contacts", len(birthdays))
|
||||
return birthdays, nil
|
||||
}
|
23
go.mod
Normal file
23
go.mod
Normal file
|
@ -0,0 +1,23 @@
|
|||
module git.luzifer.io/luzifer/birthday-notifier
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
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
|
||||
github.com/gregdel/pushover v1.3.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.8.4
|
||||
)
|
||||
|
||||
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
|
||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
42
go.sum
Normal file
42
go.sum
Normal file
|
@ -0,0 +1,42 @@
|
|||
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=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.5.0 h1:Ak/BQLgAihJt/UxJbCsEXDPxS5Uw4nZzgIMOq3rkKjc=
|
||||
github.com/emersion/go-webdav v0.5.0/go.mod h1:ycyIzTelG5pHln4t+Y32/zBvmrM7+mV7x+V+Gx4ZQno=
|
||||
github.com/gregdel/pushover v1.3.0 h1:CewbxqsThoN/1imgwkDKFkRkltaQMoyBV0K9IquQLtw=
|
||||
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/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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
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=
|
||||
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.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=
|
124
main.go
Normal file
124
main.go
Normal file
|
@ -0,0 +1,124 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"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 (one of: log, pushover)"`
|
||||
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"`
|
||||
}{}
|
||||
|
||||
birthdays []birthdayEntry
|
||||
birthdaysLock sync.Mutex
|
||||
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func initApp() error {
|
||||
rconfig.AutoEnv(true)
|
||||
if err := rconfig.ParseAndValidate(&cfg); err != nil {
|
||||
return errors.Wrap(err, "parsing cli options")
|
||||
}
|
||||
|
||||
l, err := logrus.ParseLevel(cfg.LogLevel)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parsing log-level")
|
||||
}
|
||||
logrus.SetLevel(l)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
if err = initApp(); err != nil {
|
||||
logrus.WithError(err).Fatal("initializing app")
|
||||
}
|
||||
|
||||
if cfg.VersionAndExit {
|
||||
logrus.WithField("version", version).Info("birthday-notifier")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
notify := getNotifierByName(cfg.NotifyVia)
|
||||
if notify == nil {
|
||||
logrus.Fatal("unknown notifier specified")
|
||||
}
|
||||
|
||||
if birthdays, err = fetchBirthdays(); 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), func() {
|
||||
birthdaysLock.Lock()
|
||||
defer birthdaysLock.Unlock()
|
||||
|
||||
if birthdays, err = fetchBirthdays(); err != nil {
|
||||
logrus.WithError(err).Error("updating birthdays")
|
||||
}
|
||||
}); err != nil {
|
||||
logrus.WithError(err).Fatal("adding update-cron")
|
||||
}
|
||||
|
||||
// Send notifications at midnight
|
||||
if _, err = crontab.AddFunc("@midnight", func() {
|
||||
birthdaysLock.Lock()
|
||||
defer birthdaysLock.Unlock()
|
||||
|
||||
for _, b := range birthdays {
|
||||
for _, advanceDays := range append(cfg.NotifyDaysInAdvance, 0) {
|
||||
if !dateutil.IsToday(notifyDate(dateutil.ProjectToNextBirthday(b.birthday), advanceDays)) {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(contact vcard.Card, when time.Time) {
|
||||
if err = notify.SendNotification(contact, when); err != nil {
|
||||
logrus.
|
||||
WithError(err).
|
||||
WithField("name", contact.Get(vcard.FieldFormattedName).Value).
|
||||
Error("sending notification")
|
||||
}
|
||||
}(b.contact, b.birthday)
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
logrus.WithError(err).Fatal("adding update-cron")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"advance": cfg.NotifyDaysInAdvance,
|
||||
"version": version,
|
||||
}).Info("birthday-notifier started")
|
||||
crontab.Start()
|
||||
|
||||
for {
|
||||
select {}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
20
notifier.go
Normal file
20
notifier.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier/log"
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier/pushover"
|
||||
)
|
||||
|
||||
func getNotifierByName(name string) notifier.Notifier {
|
||||
switch name {
|
||||
case "log":
|
||||
return log.Notifier{}
|
||||
|
||||
case "pushover":
|
||||
return pushover.Notifier{}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
25
pkg/dateutil/date.go
Normal file
25
pkg/dateutil/date.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package dateutil
|
||||
|
||||
import "time"
|
||||
|
||||
// IsToday uses ProjectToNextBirthday to get the next birthday and
|
||||
// compares it to TodayStartOfDay
|
||||
func IsToday(t time.Time) bool {
|
||||
return ProjectToNextBirthday(t).
|
||||
Equal(TodayStartOfDay())
|
||||
}
|
||||
|
||||
// ProjectToNextBirthday takes a birth date and projects it to the
|
||||
// next birthday being today or later
|
||||
func ProjectToNextBirthday(t time.Time) time.Time {
|
||||
projected := time.Date(time.Now().Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||
if projected.Before(TodayStartOfDay()) {
|
||||
projected = time.Date(time.Now().Year()+1, t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
// TodayStartOfDay gets the start of the current day
|
||||
func TodayStartOfDay() time.Time {
|
||||
return time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
54
pkg/dateutil/date_test.go
Normal file
54
pkg/dateutil/date_test.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package dateutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const timeDay = 24 * time.Hour
|
||||
|
||||
func TestProjectToNextBirthday(t *testing.T) {
|
||||
// Now should stay in the year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year(),
|
||||
ProjectToNextBirthday(time.Now()).Year(),
|
||||
)
|
||||
|
||||
// Start-of-day should stay in the year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year(),
|
||||
ProjectToNextBirthday(TodayStartOfDay()).Year(),
|
||||
)
|
||||
|
||||
// Tomorrow should stay in the year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year(),
|
||||
ProjectToNextBirthday(time.Now().Add(timeDay)).Year(),
|
||||
)
|
||||
|
||||
// Yesterday should go to next year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year()+1,
|
||||
ProjectToNextBirthday(time.Now().Add(-timeDay)).Year(),
|
||||
)
|
||||
|
||||
// Yesterday, thirty years ago should go to next year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year()+1,
|
||||
ProjectToNextBirthday(time.Date(time.Now().Year()-30, time.Now().Month(), time.Now().Day()-1, 0, 0, 0, 0, time.Local)).Year(),
|
||||
)
|
||||
|
||||
// Tomorrow, thirty years ago should go to this year
|
||||
assert.Equal(
|
||||
t,
|
||||
time.Now().Year(),
|
||||
ProjectToNextBirthday(time.Date(time.Now().Year()-30, time.Now().Month(), time.Now().Day()+1, 0, 0, 0, 0, time.Local)).Year(),
|
||||
)
|
||||
}
|
57
pkg/dateutil/parser.go
Normal file
57
pkg/dateutil/parser.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// Package dateutil contains a helper to parse vcard dates
|
||||
package dateutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
)
|
||||
|
||||
// Parse parses a vcard.Field into a time.Time
|
||||
func Parse(field *vcard.Field) (d time.Time, err error) {
|
||||
if field == nil {
|
||||
return d, fmt.Errorf("nil-field given")
|
||||
}
|
||||
|
||||
rawDate := field.Value
|
||||
|
||||
if field.Params.Get("X-APPLE-OMIT-YEAR") != "" {
|
||||
// Yay, Apple bullshit. They don't use the proper way defined in
|
||||
// the RFC to omit the year but specify an invalid format with a
|
||||
// replace. As the year 0001 is the "zero-time" in Go we replace
|
||||
// the defined year (likely 1604) and move it to the year 1.
|
||||
//
|
||||
// Field should be something like this:
|
||||
// &{1604-09-13 map[X-APPLE-OMIT-YEAR:[1604]] }
|
||||
|
||||
rawDate = strings.Replace(rawDate, field.Params.Get("X-APPLE-OMIT-YEAR"), "0001", 1)
|
||||
}
|
||||
|
||||
// And now as we can't rely on `VALUE=DATE` being set (thanks Sabre)
|
||||
// we're trying to walk possible formats until we found a matching
|
||||
// one…
|
||||
|
||||
for _, fmtCandidate := range []string{
|
||||
"20060102", // Most likely, test first
|
||||
"2006-01-02", // Invalid as of RFC, used by Apple, see above
|
||||
"060102", // RFC compliant with 2-digit year
|
||||
"--0102", // RFC-compliant omit-year
|
||||
"2006-01", // Shouldn't exist as no day present but is valid
|
||||
"2006", // Somewhere in the year
|
||||
"20060102T150405", // Full DATE-TIME in RFC
|
||||
"20060102T1504", // DATE-TIME in RFC without seconds
|
||||
"20060102T15", // DATE-TIME in RFC without minutes & seconds
|
||||
} {
|
||||
d, err = time.ParseInLocation(fmtCandidate, rawDate, time.Local)
|
||||
if err == nil {
|
||||
// Yay! It matched. Or at least it had the right length and numbers
|
||||
// at the right places so it SHOULD have matched.
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Well. We found no matching format.
|
||||
return d, fmt.Errorf("no format defined for %q", rawDate)
|
||||
}
|
49
pkg/dateutil/parser_test.go
Normal file
49
pkg/dateutil/parser_test.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package dateutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseDate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ExpectedTime time.Time
|
||||
Field *vcard.Field
|
||||
}{
|
||||
{
|
||||
ExpectedTime: time.Date(2000, 2, 5, 0, 0, 0, 0, time.Local),
|
||||
Field: &vcard.Field{
|
||||
Value: "20000205",
|
||||
Params: vcard.Params{
|
||||
"VALUE": []string{"DATE"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ExpectedTime: time.Date(1, 2, 15, 0, 0, 0, 0, time.Local),
|
||||
Field: &vcard.Field{
|
||||
Value: "1604-02-15",
|
||||
Params: vcard.Params{
|
||||
"X-APPLE-OMIT-YEAR": []string{"1604"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ExpectedTime: time.Date(1, 11, 2, 0, 0, 0, 0, time.Local),
|
||||
Field: &vcard.Field{
|
||||
Value: "20221102",
|
||||
Params: vcard.Params{
|
||||
"X-APPLE-OMIT-YEAR": []string{"2022"},
|
||||
"VALUE": []string{"DATE"},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
d, err := Parse(tc.Field)
|
||||
assert.NoError(t, err, tc.Field)
|
||||
assert.Equal(t, tc.ExpectedTime, d)
|
||||
}
|
||||
}
|
72
pkg/formatter/formatter.go
Normal file
72
pkg/formatter/formatter.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Package formatter contains a helper to format the date of a birthday
|
||||
// into a notification text
|
||||
package formatter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
|
||||
"github.com/emersion/go-vcard"
|
||||
)
|
||||
|
||||
const timeDay = 24 * time.Hour
|
||||
|
||||
var (
|
||||
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", " ")), " ")
|
||||
|
||||
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) {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err = notifyTpl.Execute(buf, map[string]any{
|
||||
"contact": contact,
|
||||
"when": when,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func getAge(t time.Time) int {
|
||||
return dateutil.ProjectToNextBirthday(t).Year() - t.Year()
|
||||
}
|
||||
|
||||
func getContactName(contact vcard.Card) string {
|
||||
if contact.Name() != nil && contact.Name().GivenName != "" {
|
||||
return contact.Name().GivenName
|
||||
}
|
||||
|
||||
return contact.FormattedNames()[0].Value
|
||||
}
|
56
pkg/formatter/formatter_test.go
Normal file
56
pkg/formatter/formatter_test.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package formatter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/dateutil"
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func getTestVCard(t *testing.T, content string) vcard.Card {
|
||||
c, err := vcard.NewDecoder(strings.NewReader(content)).Decode()
|
||||
require.NoError(t, err)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func TestFormatNotificationText(t *testing.T) {
|
||||
card := getTestVCard(t, `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Bloggs;Joe;;;
|
||||
FN:Joe Bloggs
|
||||
EMAIL;TYPE=home;PREF=1:me@joebloggs.com
|
||||
TEL;TYPE="cell,home";PREF=1:tel:+44 20 1234 5678
|
||||
ADR;TYPE=home;PREF=1:;;1 Trafalgar Square;London;;WC2N;United Kingdom
|
||||
URL;TYPE=home;PREF=1:http://joebloggs.com
|
||||
IMPP;TYPE=home;PREF=1:skype:joe.bloggs
|
||||
X-SOCIALPROFILE;TYPE=home;PREF=1:twitter:https://twitter.com/joebloggs
|
||||
END:VCARD`)
|
||||
|
||||
bday := time.Date(time.Now().Year()-30, time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local)
|
||||
|
||||
txt, err := FormatNotificationText(card, bday)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Joe has their birthday today. They are turning 30.", txt)
|
||||
|
||||
bday = bday.Add(timeDay)
|
||||
txt, err = FormatNotificationText(card, bday)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"Joe has their birthday on %s. They are turning 30.",
|
||||
time.Now().Add(timeDay).Format("Mon, 02 Jan"),
|
||||
), txt)
|
||||
|
||||
bday = bday.Add(-2 * timeDay)
|
||||
txt, err = FormatNotificationText(card, bday)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"Joe has their birthday on %s. They are turning 31.",
|
||||
dateutil.ProjectToNextBirthday(time.Now().Add(-timeDay)).Format("Mon, 02 Jan"),
|
||||
), txt)
|
||||
}
|
34
pkg/notifier/log/log.go
Normal file
34
pkg/notifier/log/log.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
// Package log contains a log-notifier for debugging
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type (
|
||||
// Notifier implements the notifier interface
|
||||
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")
|
||||
}
|
||||
|
||||
text, err := formatter.FormatNotificationText(contact, when)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rendering text: %w", err)
|
||||
}
|
||||
|
||||
logrus.WithField("name", contact.Name().GivenName).Info(text)
|
||||
return nil
|
||||
}
|
19
pkg/notifier/notifier.go
Normal file
19
pkg/notifier/notifier.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Package notifier includes the interface to implement in a notifier
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
)
|
||||
|
||||
type (
|
||||
// Notifier specifies what a Notifier can do
|
||||
Notifier interface {
|
||||
// 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
|
||||
}
|
||||
)
|
69
pkg/notifier/pushover/pushover.go
Normal file
69
pkg/notifier/pushover/pushover.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
// Package pushover provides a notifier to send birthday notifications
|
||||
// using Pushover.net
|
||||
package pushover
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/formatter"
|
||||
"git.luzifer.io/luzifer/birthday-notifier/pkg/notifier"
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/gregdel/pushover"
|
||||
)
|
||||
|
||||
type (
|
||||
// Notifier implements the notifier interface
|
||||
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 (
|
||||
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)
|
||||
}
|
||||
|
||||
var title string
|
||||
for _, fn := range contact.FormattedNames() {
|
||||
if fn.Value != "" {
|
||||
title = fmt.Sprintf("%s (Birthday)", fn.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%s %s (Birthday)", contact.Name().GivenName, contact.Name().FamilyName)
|
||||
}
|
||||
|
||||
message := &pushover.Message{
|
||||
Message: text,
|
||||
Title: title,
|
||||
Sound: os.Getenv("PUSHOVER_SOUND"),
|
||||
}
|
||||
|
||||
if _, err = pushover.New(apiToken).
|
||||
SendMessage(message, pushover.NewRecipient(userKey)); err != nil {
|
||||
return fmt.Errorf("sending notification: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Reference in a new issue