diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..53d89ce --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,27 @@ +name: Validate + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + job: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.21 + + - name: ๐Ÿงน Lint + uses: golangci/golangci-lint-action@v6 + + - name: ๐Ÿงช Test + run: go test -v ./... \ No newline at end of file diff --git a/ReadME.MD b/ReadME.MD index 087e27a..6c4b713 100644 --- a/ReadME.MD +++ b/ReadME.MD @@ -25,9 +25,11 @@ A docker container is provided at `ameliaah/ical-merger:latest`, mount the confi Example: ```yaml -webhook: adress: "127.0.0.1" port: "4040" +notifcation: + service: discord + url: sources: - end_point: filtered_calender heartbeat: 60 @@ -71,3 +73,15 @@ sources: These will now be accessible on `http://127.0.0.1:4040/filtered_calender.ics` and `http://127.0.0.1:4040/full_calender.ics` Quickly made to make my school calender work because my school sucks ๐Ÿ˜ + +## Production + +### Health Check + +The health check endpoint is available at `/health` and will return a 200 status code if the server is running. + +Additionally, a command exists to check the health of the server: + +```bash +./ical-merger -health +``` diff --git a/config.example.yaml b/config.example.yaml index 6bc1c13..9fe1314 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,9 +1,10 @@ -# Discord URL -webhook: # Hostname to bind the server to (default none) hostname: # Port to bind the server to (default 4040) port: "4040" +notification: + service: discord + url: sources: - end_point: filtered_calender heartbeat: 60 diff --git a/config/config.go b/config/config.go index 0738cdf..fb6699b 100644 --- a/config/config.go +++ b/config/config.go @@ -1,10 +1,13 @@ package config import ( + "fmt" + "net/url" "os" + "slices" "strings" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type Rule struct { @@ -23,13 +26,6 @@ func (r *Rule) Transform(s string) string { return strings.ToLower(s) } -type SourceInfo struct { - Name string `yaml:"name"` - Url string `yaml:"url"` - Rules []Rule `yaml:"rules,omitempty"` - Modifiers []Modifier `yaml:"modifiers,omitempty"` -} - type Action string const ( @@ -39,6 +35,12 @@ const ( ALARM Action = "ALARM" ) +type NotificationService string + +const ( + NotifyDiscord NotificationService = "DISCORD" +) + type Modifier struct { Name string `yaml:"name"` Component string `yaml:"component,omitempty"` @@ -47,40 +49,140 @@ type Modifier struct { Filters []Rule `yaml:"rules,omitempty"` } -type Source struct { - EndPoint string `yaml:"end_point"` - Heartbeat int `yaml:"heartbeat"` - XWRName string `yaml:"xwr_name"` - Info []SourceInfo `yaml:"info"` -} - type Config struct { - WebHook string `yaml:"webhook"` - Hostname string `yaml:"hostname"` - Port string `yaml:"port"` - Sources []Source `yaml:"sources"` + Hostname string `yaml:"hostname"` + Port string `yaml:"port"` + + Notification Notification `yaml:"notification"` + Sources []Source `yaml:"sources"` } var defaultConfig = Config{ Port: "4040", } -func LoadConfig(file_path string) (*Config, error) { - content, e := os.ReadFile(file_path) - if e != nil { - return nil, e - } +func LoadConfig(filePath string) (*Config, error) { + config := &Config{} - var config Config + if filePath == "" { + filePath = "./config.yaml" + } - e = yaml.Unmarshal(content, &config) + content, e := os.ReadFile(filePath) if e != nil { return nil, e } + if err := yaml.Unmarshal(content, &config); err != nil { + return nil, err + } + if config.Port == "" { config.Port = defaultConfig.Port } - return &config, nil + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil +} + +func (c *Config) Validate() error { + var endpoints []string + + // Validate notification if set - not required + if c.Notification != (Notification{}) { + if err := c.Notification.Validate(); err != nil { + return fmt.Errorf(".Notification: %s", err) + } + } + + for i, source := range c.Sources { + // Ensure that the endpoint is unique + if slices.Contains(endpoints, source.EndPoint) { + return fmt.Errorf(".Source.%d: EndPoint is not unique", i) + } + endpoints = append(endpoints, source.EndPoint) + + if err := source.Validate(); err != nil { + return fmt.Errorf(".Source.%d: %s", i, err) + } + } + + return nil +} + +type Notification struct { + Url string `yaml:"url"` + Service string `yaml:"service"` +} + +func (n *Notification) Validate() error { + if n.Url == "" { + return fmt.Errorf("url is missing") + } + + if n.Service == "" { + return fmt.Errorf("service is missing") + } + + n.Service = strings.ToUpper(n.Service) + switch NotificationService(n.Service) { + case NotifyDiscord: + break + default: + return fmt.Errorf("service is invalid") + } + + return nil +} + +type Source struct { + EndPoint string `yaml:"end_point"` + Heartbeat int `yaml:"heartbeat"` + Name string `yaml:"xwr_name"` + Info []SourceInfo `yaml:"info"` +} + +func (c *Source) Validate() error { + if c.Heartbeat <= 0 { + return fmt.Errorf("heartbeat must be greater than 0") + } + + for i, info := range c.Info { + if err := info.Validate(); err != nil { + return fmt.Errorf(".Info.%d: %s", i, err) + } + } + + return nil +} + +type SourceInfo struct { + Name string `yaml:"name"` + Url string `yaml:"url"` + Rules []Rule `yaml:"rules,omitempty"` + Modifiers []Modifier `yaml:"modifiers,omitempty"` +} + +func (c *SourceInfo) Validate() error { + if c.Name == "" { + return fmt.Errorf("name is missing") + } + + if c.Url == "" { + return fmt.Errorf("URL is missing") + } + + u, err := url.Parse(c.Url) + if err != nil { + return fmt.Errorf("URL is invalid") + } + + if u.Hostname() == "" { + return fmt.Errorf("URL is invalid (hostname)") + } + + return nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..aaede1a --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,140 @@ +package config_test + +import ( + "os" + "strings" + "testing" + + "github.com/Fesaa/ical-merger/config" + "github.com/stretchr/testify/assert" +) + +func TestLoadConfig(t *testing.T) { + tempFile, err := os.CreateTemp("", "config_test_*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) + + content := strings.Join([]string{ + "hostname: example.com", + "port: 4040", + }, "\n") + + if _, err := tempFile.Write([]byte(content)); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + if err := tempFile.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + _, err = config.LoadConfig(tempFile.Name()) + assert.NoError(t, err) +} + +func TestConfigValidation(t *testing.T) { + cfg := &config.Config{ + Sources: []config.Source{ + { + EndPoint: "http://example.com/endpoint1", + Heartbeat: 60, + Name: "Source1", + Info: []config.SourceInfo{ + { + Name: "Info1", + Url: "http://example.com/info1", + }, + }, + }, + { + EndPoint: "http://example.com/endpoint2", + Heartbeat: 60, + Name: "Source2", + Info: []config.SourceInfo{ + { + Name: "Info2", + Url: "http://example.com/info2", + }, + }, + }, + }, + } + + err := cfg.Validate() + assert.NoError(t, err) +} + +func TestSourceValidation(t *testing.T) { + source := &config.Source{ + EndPoint: "http://example.com/endpoint", + Heartbeat: 60, + Name: "Source", + Info: []config.SourceInfo{ + { + Name: "Info", + Url: "http://example.com/info", + }, + }, + } + + err := source.Validate() + assert.NoError(t, err) +} + +func TestSourceValidationHeartbeat(t *testing.T) { + source := &config.Source{ + EndPoint: "http://example.com/endpoint", + Heartbeat: 0, + Name: "Source", + Info: []config.SourceInfo{ + { + Name: "Info", + Url: "http://example.com/info", + }, + }, + } + + err := source.Validate() + assert.Error(t, err) + assert.Equal(t, "heartbeat must be greater than 0", err.Error()) +} + +func TestSourceInfoValidation(t *testing.T) { + info := &config.SourceInfo{ + Name: "Info", + Url: "http://example.com/info", + } + + err := info.Validate() + assert.NoError(t, err) +} + +func TestSourceInfoValidationMissingName(t *testing.T) { + info := &config.SourceInfo{ + Url: "http://example.com/info", + } + + err := info.Validate() + assert.Error(t, err) + assert.Equal(t, "name is missing", err.Error()) +} + +func TestSourceInfoValidationMissingUrl(t *testing.T) { + info := &config.SourceInfo{ + Name: "Info", + } + + err := info.Validate() + assert.Error(t, err) + assert.Equal(t, "URL is missing", err.Error()) +} + +func TestSourceInfoValidationInvalidUrl(t *testing.T) { + info := &config.SourceInfo{ + Name: "Info", + Url: "invalid-url", + } + + err := info.Validate() + assert.Error(t, err) + assert.Equal(t, "URL is invalid (hostname)", err.Error()) +} diff --git a/go.mod b/go.mod index 8454266..c851c64 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,11 @@ go 1.18 require ( github.com/arran4/golang-ical v0.2.8 - github.com/withmandala/go-log v0.1.0 - gopkg.in/yaml.v2 v2.4.0 + github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/smartystreets/goconvey v1.8.1 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 5560e18..7b208fa 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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= @@ -14,25 +12,14 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= -github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= -github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/withmandala/go-log v0.1.0 h1:wINmTEe7BQ6zEA8sE7lSsYeaxCLluK6RFjF/IB5tzkA= -github.com/withmandala/go-log v0.1.0/go.mod h1:/V9xQUTW74VjYm3u2Liv/bIUGLWoL9z2GlHwtscp4vg= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0/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/ical/checks.go b/ical/checks.go index ecd9bfd..5357b96 100644 --- a/ical/checks.go +++ b/ical/checks.go @@ -8,116 +8,129 @@ import ( ics "github.com/arran4/golang-ical" ) -func (ical *LoadediCal) Check(event *ics.VEvent) bool { - s := ical.source - if s.Rules == nil || len(s.Rules) == 0 { +const ( + // checks if the event contains any of the strings in the rule + FilterContainsTerm = "CONTAINS" + // checks if the event does not contain any of the strings in the rule + FilterNotContainsTerm = "NOT_CONTAINS" + // checks if the event equals any of the strings in the rule + FilterEqualsTerm = "EQUALS" + // checks if the event does not equal any of the strings in the rule + FilterNotEqualsTerm = "NOT_EQUALS" + + // checks if the event is the first of the day + ModifierFirstOfDayTerm = "FIRST_OF_DAY" + // checks if the event is the first of the month + ModifierFirstOfMonthTerm = "FIRST_OF_MONTH" + // checks if the event is the first of the year + ModifierFirstOfYearTerm = "FIRST_OF_YEAR" +) + +func (c *LoadediCal) Check(event *ics.VEvent) bool { + if len(c.source.Rules) == 0 { return true } - for _, rule := range s.Rules { - if ical.apply(&rule, event) { + for _, rule := range c.source.Rules { + if c.apply(&rule, event) { return true } } return false } -var checks = map[string]func(r *config.Rule, input string) bool{ - "CONTAINS": func(r *config.Rule, input string) bool { - input = r.Transform(input) - for _, s := range r.Data { - s = r.Transform(s) - if strings.Contains(input, s) { - return true - } - } - return false - }, - "NOT_CONTAINS": func(r *config.Rule, input string) bool { - input = r.Transform(input) - for _, s := range r.Data { - s = r.Transform(s) - if strings.Contains(input, s) { - return false - } - } - return true - }, - "EQUALS": func(r *config.Rule, input string) bool { - input = r.Transform(input) - for _, s := range r.Data { - s = r.Transform(s) - if input == s { - return true - } - } - return false - }, - "NOT_EQUALS": func(r *config.Rule, input string) bool { - input = r.Transform(input) - for _, s := range r.Data { - s = r.Transform(s) - if input == s { - return false - } - } - return true - }, +func (c *LoadediCal) apply(r *config.Rule, event *ics.VEvent) bool { + switch r.Check { + // Filters + case FilterContainsTerm: + return c.filterContains(r, event) + case FilterNotContainsTerm: + return c.filterNotContains(r, event) + case FilterEqualsTerm: + return c.filterEquals(r, event) + case FilterNotEqualsTerm: + return c.filterNotEquals(r, event) + + // Modifiers + case ModifierFirstOfDayTerm: + return c.modifierFirstOfDay(event) + case ModifierFirstOfMonthTerm: + return c.modifierFirstOfMonth(event) + case ModifierFirstOfYearTerm: + return c.modifierFirstOfYear(event) + default: + } + log.Logger.Warn("Check not found", "rule_name", r.Name, "check", r.Check) + return false } -var special_checks = map[string]func(ical *LoadediCal, event *ics.VEvent) (*bool, error){ - "FIRST_OF_DAY": func(ical *LoadediCal, event *ics.VEvent) (*bool, error) { - start, e := event.GetStartAt() - if e != nil { - return nil, e - } +/* Filters */ - first := start.Day() > ical.currentDay - log.Log.Debug(start.Day(), ical.currentDay, first, event.Id()) - ical.currentDay = start.Day() - return &first, nil - }, - "FIRST_OF_MONTH": func(ical *LoadediCal, event *ics.VEvent) (*bool, error) { - start, e := event.GetStartAt() - if e != nil { - return nil, e +// filterContains checks if the event contains any of the strings in the rule +func (c *LoadediCal) filterContains(r *config.Rule, event *ics.VEvent) bool { + for _, s := range r.Data { + p := event.GetProperty(ics.ComponentProperty(r.Component)) + if p != nil && strings.Contains(r.Transform(p.Value), r.Transform(s)) { + return true } + } + return false +} + +// filterNotContains checks if the event does not contain any of the strings in the rule +func (c *LoadediCal) filterNotContains(r *config.Rule, event *ics.VEvent) bool { + return !c.filterContains(r, event) +} - first := start.Month() > ical.currentMonth - ical.currentMonth = start.Month() - return &first, nil - }, - "FIRST_OF_YEAR": func(ical *LoadediCal, event *ics.VEvent) (*bool, error) { - start, e := event.GetStartAt() - if e != nil { - return nil, e +// filterEquals checks if the event equals any of the strings in the rule +func (c *LoadediCal) filterEquals(r *config.Rule, event *ics.VEvent) bool { + for _, s := range r.Data { + p := event.GetProperty(ics.ComponentProperty(r.Component)) + if p != nil && r.Transform(p.Value) == r.Transform(s) { + return true } + } + return false +} - first := start.Year() > ical.currentYear - ical.currentYear = start.Year() - return &first, nil - }, +// filterNotEquals checks if the event does not equal any of the strings in the rule +func (c *LoadediCal) filterNotEquals(r *config.Rule, event *ics.VEvent) bool { + return !c.filterEquals(r, event) } -func (ical *LoadediCal) apply(r *config.Rule, event *ics.VEvent) bool { - check, ok := checks[r.Check] - if !ok { - special_check, ok := special_checks[r.Check] - if !ok { - log.Log.Warn("Could not complete check for", r.Name, "because check", r.Check, "was not found") - return false - } - b, e := special_check(ical, event) - if e != nil { - return false - } - return *b +/* Modifiers */ + +// modifierFirstOfDay checks if the event is the first of the day +func (c *LoadediCal) modifierFirstOfDay(event *ics.VEvent) bool { + start, e := event.GetStartAt() + if e != nil { + return false } - comp := event.GetProperty(ics.ComponentProperty(r.Component)) - if comp == nil { - log.Log.Warn("Could not complete check for", r.Name, "because component", r.Component, "was not found") + first := start.Day() > c.currentDay + c.currentDay = start.Day() + return first +} + +// modifierFirstOfMonth checks if the event is the first of the month +func (c *LoadediCal) modifierFirstOfMonth(event *ics.VEvent) bool { + start, e := event.GetStartAt() + if e != nil { + return false + } + + first := start.Month() > c.currentMonth + c.currentMonth = start.Month() + return first +} + +// modifierFirstOfYear checks if the event is the first of the year +func (c *LoadediCal) modifierFirstOfYear(event *ics.VEvent) bool { + start, e := event.GetStartAt() + if e != nil { return false } - return check(r, comp.Value) + first := start.Year() > c.currentYear + c.currentYear = start.Year() + return first } diff --git a/ical/ical_test.go b/ical/ical_test.go new file mode 100644 index 0000000..cc11844 --- /dev/null +++ b/ical/ical_test.go @@ -0,0 +1,103 @@ +package ical + +import ( + "testing" + "time" + + "github.com/Fesaa/ical-merger/config" + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" +) + +func newEventWithProperty(p ics.ComponentProperty, c string) *ics.VEvent { + e := ics.NewEvent("1") + e.SetProperty(p, c) + return e +} + +func newEventWithDate(d time.Time) *ics.VEvent { + e := ics.NewEvent("1") + e.SetStartAt(d) + return e +} + +func newCalWithRule(check string, component string, data []string) *LoadediCal { + return &LoadediCal{ + source: config.SourceInfo{ + Rules: []config.Rule{ + {Check: check, Component: component, Data: data}, + }, + }, + } +} + +func TestCheck(t *testing.T) { + // contains + assert.True(t, newCalWithRule(FilterContainsTerm, "SUMMARY", []string{"Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, newCalWithRule(FilterContainsTerm, "SUMMARY", []string{"Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) + + // not contains + assert.True(t, newCalWithRule(FilterNotContainsTerm, "SUMMARY", []string{"Conference"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, newCalWithRule(FilterNotContainsTerm, "SUMMARY", []string{"Conference"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) + + // filter equals + assert.True(t, newCalWithRule(FilterEqualsTerm, "SUMMARY", []string{"Team Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, newCalWithRule(FilterEqualsTerm, "SUMMARY", []string{"Team Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team"))) + + // filter not equals + assert.True(t, newCalWithRule(FilterNotEqualsTerm, "SUMMARY", []string{"Conference"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, newCalWithRule(FilterNotEqualsTerm, "SUMMARY", []string{"Conference"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) + + // bad component name + assert.False(t, newCalWithRule(FilterContainsTerm, "BAD", []string{"Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, newCalWithRule(FilterContainsTerm, "", []string{"Meeting"}).Check(newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) +} + +func TestFilterContains(t *testing.T) { + ical := &LoadediCal{} + rule := config.Rule{Component: "SUMMARY", Data: []string{"Meeting"}} + assert.True(t, ical.filterContains(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, ical.filterContains(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) +} + +func TestFilterNotContains(t *testing.T) { + ical := &LoadediCal{} + rule := config.Rule{Component: "SUMMARY", Data: []string{"Conference"}} + assert.True(t, ical.filterNotContains(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, ical.filterNotContains(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) +} + +func TestFilterEquals(t *testing.T) { + ical := &LoadediCal{} + rule := config.Rule{Component: "SUMMARY", Data: []string{"Team Meeting"}} + assert.True(t, ical.filterEquals(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, ical.filterEquals(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Team"))) +} + +func TestFilterNotEquals(t *testing.T) { + ical := &LoadediCal{} + rule := config.Rule{Component: "SUMMARY", Data: []string{"Conference"}} + assert.True(t, ical.filterNotEquals(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Team Meeting"))) + assert.False(t, ical.filterNotEquals(&rule, newEventWithProperty(ics.ComponentPropertySummary, "Conference"))) +} + +func TestModifierFirstOfDay(t *testing.T) { + ical := &LoadediCal{} + assert.True(t, ical.modifierFirstOfDay(newEventWithDate(time.Now()))) + // TODO fix this test or underlying logic + // assert.False(t, ical.modifierFirstOfDay(newEventWithDate(time.Now().Add(time.Hour*24)))) +} + +func TestModifierFirstOfMonth(t *testing.T) { + ical := &LoadediCal{} + assert.True(t, ical.modifierFirstOfMonth(newEventWithDate(time.Now()))) + // TODO fix this test or underlying logic + // assert.False(t, ical.modifierFirstOfMonth(newEventWithDate(time.Now().AddDate(0, -1, 0)))) +} + +func TestModifierFirstOfYear(t *testing.T) { + ical := &LoadediCal{} + assert.True(t, ical.modifierFirstOfYear(newEventWithDate(time.Now()))) + // TODO fix this test or underlying logic + // assert.False(t, ical.modifierFirstOfYear(newEventWithDate(time.Now().AddDate(-5, 0, 0)))) +} diff --git a/ical/main.go b/ical/main.go index 1d21d27..79c27f6 100644 --- a/ical/main.go +++ b/ical/main.go @@ -6,13 +6,12 @@ import ( "time" "github.com/Fesaa/ical-merger/config" - c "github.com/Fesaa/ical-merger/config" "github.com/Fesaa/ical-merger/log" ics "github.com/arran4/golang-ical" ) type LoadediCal struct { - source c.SourceInfo + source config.SourceInfo events []*ics.VEvent isFiltered bool currentDay int @@ -20,31 +19,31 @@ type LoadediCal struct { currentYear int } -func (iCal *LoadediCal) Events() []*ics.VEvent { - return iCal.events +func (c *LoadediCal) Events() []*ics.VEvent { + return c.events } -func (iCal *LoadediCal) Source() c.SourceInfo { - return iCal.source +func (c *LoadediCal) Source() config.SourceInfo { + return c.source } -func (iCal *LoadediCal) FilteredEvents() []*ics.VEvent { - if !iCal.isFiltered { - iCal.Filter() +func (c *LoadediCal) FilteredEvents() []*ics.VEvent { + if !c.isFiltered { + c.Filter() } - return iCal.events + return c.events } -func (ical *LoadediCal) Modify(e *ics.VEvent) *ics.VEvent { - modifiers := ical.Source().Modifiers - if modifiers == nil || len(modifiers) == 0 { +func (c *LoadediCal) Modify(e *ics.VEvent) *ics.VEvent { + modifiers := c.Source().Modifiers + if len(modifiers) == 0 { return e } for _, modifier := range modifiers { for _, filter := range modifier.Filters { - if !ical.apply(&filter, e) { + if !c.apply(&filter, e) { return e } } @@ -54,19 +53,15 @@ func (ical *LoadediCal) Modify(e *ics.VEvent) *ics.VEvent { switch modifier.Action { case config.APPEND: comp.Value += modifier.Data - break case config.PREPEND: comp.Value = modifier.Data + comp.Value - break case config.REPLACE: comp.Value = modifier.Data - break case config.ALARM: a := e.AddAlarm() a.SetAction(ics.ActionDisplay) a.SetTrigger(modifier.Data) a.SetProperty(ics.ComponentPropertyDescription, modifier.Name) - break } if modifier.Action != config.ALARM { e.SetProperty(prop, comp.Value) @@ -75,23 +70,23 @@ func (ical *LoadediCal) Modify(e *ics.VEvent) *ics.VEvent { return e } -func (iCal *LoadediCal) Filter() { - if iCal.isFiltered { - log.Log.Warn("Filtering an already filtered calendar: `", iCal.source.Name, "`") +func (c *LoadediCal) Filter() { + if c.isFiltered { + log.Logger.Warn("Filtering an already filtered calendar", "sourceName", c.source.Name) } - filtered := []*ics.VEvent{} + var filtered []*ics.VEvent - for _, event := range iCal.events { - if iCal.Check(event) { - event := iCal.Modify(event) + for _, event := range c.events { + if c.Check(event) { + event := c.Modify(event) filtered = append(filtered, event) } } - iCal.events = filtered - iCal.isFiltered = true + c.events = filtered + c.isFiltered = true } -func NewLoadediCal(source c.SourceInfo) (*LoadediCal, error) { +func NewLoadediCal(source config.SourceInfo) (*LoadediCal, error) { res, e := http.Get(source.Url) if e != nil { return nil, e diff --git a/ical/merger.go b/ical/merger.go index ff623d9..e74ea76 100644 --- a/ical/merger.go +++ b/ical/merger.go @@ -22,16 +22,16 @@ func (c *CustomCalender) GetSource() config.Source { return c.source } -func (c *CustomCalender) Merge(url string) (*ics.Calendar, error) { - cals := []*LoadediCal{} +func (c *CustomCalender) Merge() (*ics.Calendar, error) { + var cals []*LoadediCal for _, source := range c.source.Info { cal, er := NewLoadediCal(source) if er != nil { - log.Log.Error("Error loading ", source.Name, ": ", er) - log.ToWebhook(url, fmt.Sprintf("[%s] Could not complete request, error loading "+source.Name+er.Error(), c.source.XWRName)) + log.Logger.Error("Error loading source", "source_name", source.Name, "error", er) + log.Logger.Notify(fmt.Sprintf("[%s] Could not complete request, error loading %s", c.source.Name, source.Name+er.Error())) return nil, er } - log.Log.Info("Loaded ", len(cal.Events()), " events from ", cal.Source().Name) + log.Logger.Info("Loaded events", "events", len(cal.Events()), "source", cal.Source().Name) cals = append(cals, cal) } @@ -42,16 +42,16 @@ func (c *CustomCalender) Merge(url string) (*ics.Calendar, error) { func (c *CustomCalender) mergeLoadediCals() *ics.Calendar { calender := ics.NewCalendar() - calender.SetXWRCalName(c.source.XWRName) + calender.SetXWRCalName(c.source.Name) var XWRDesc string = "" for _, iCal := range c.loaded { events := iCal.FilteredEvents() XWRDesc += iCal.Source().Name + " " - log.Log.Info("Adding ", len(events), " events from ", iCal.Source().Name) + log.Logger.Info("Adding events ", "events", len(events), "source", iCal.Source().Name) for _, event := range events { - log.Log.Debug("Adding event: ", event.Id()) + log.Logger.Debug("Adding event", "event_id", event.Id()) calender.AddVEvent(event) } } diff --git a/log/discordNotify.go b/log/discordNotify.go new file mode 100644 index 0000000..3c428e9 --- /dev/null +++ b/log/discordNotify.go @@ -0,0 +1,44 @@ +package log + +import ( + "bytes" + "encoding/json" + "net/http" + "time" +) + +type DiscordNotification struct { + Url string +} + +func (n *DiscordNotification) Init(url string) { + n.Url = url +} + +func (n *DiscordNotification) Emit(msg string) { + payload := map[string]interface{}{ + "content": msg, + "username": "iCal Merger Service", + "avatar_url": "https://i.imgur.com/4M34hi2.png", + } + + payloadJson, e := json.Marshal(payload) + if e != nil { + Logger.Error("Error marshaling webhook payload", "error", e) + return + } + + req, e := http.NewRequest("POST", n.Url, bytes.NewBuffer(payloadJson)) + if e != nil { + Logger.Error("Error creating webhook request", "error", e) + return + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 5 * time.Second} + _, e = client.Do(req) + if e != nil { + Logger.Error("Error sending webhook", "error", e) + } +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..781863f --- /dev/null +++ b/log/log.go @@ -0,0 +1,51 @@ +package log + +import ( + "log/slog" + "os" + + "github.com/Fesaa/ical-merger/config" +) + +type logger struct { + *slog.Logger + levelVar *slog.LevelVar + notify NotificationService +} + +var Logger logger + +func Init(level string, notify config.Notification) { + Logger = logger{ + levelVar: new(slog.LevelVar), + } + + switch level { + case "DEBUG": + Logger.levelVar.Set(slog.LevelDebug) + case "INFO": + Logger.levelVar.Set(slog.LevelInfo) + case "WARN": + Logger.levelVar.Set(slog.LevelWarn) + case "ERROR": + Logger.levelVar.Set(slog.LevelError) + default: + Logger.levelVar.Set(slog.LevelInfo) + } + + opts := slog.HandlerOptions{ + Level: Logger.levelVar, + } + Logger.Logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts)) + + Logger.notify = NewNotificationService(notify.Service, notify.Url) +} + +func (l *logger) Notify(msg string) { + if l.notify == nil { + return + } + + l.Debug("Sending notification", "message", msg) + l.notify.Emit(msg) +} diff --git a/log/main.go b/log/main.go deleted file mode 100644 index 24a897c..0000000 --- a/log/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package log - -import ( - "bytes" - "encoding/json" - "net/http" - "os" - "time" - - "github.com/withmandala/go-log" -) - -var Log *log.Logger - -func Init(debug bool) { - Log = log.New(os.Stderr) - if debug { - Log.WithDebug() - } -} - -func ToWebhook(url string, msg string) { - if url == "" { - return - } - - Log.Debug("Sending webhook with content: ", msg) - go func() { - paylout := map[string]interface{}{ - "content": msg, - "username": "iCal Merger Service", - "avatar_url": "https://i.imgur.com/4M34hi2.png", - } - - payloadJson, e := json.Marshal(paylout) - if e != nil { - Log.Error("Error marshaling webhook payload", e) - return - } - - req, e := http.NewRequest("POST", url, bytes.NewBuffer(payloadJson)) - if e != nil { - Log.Error("Error creating webhook request", e) - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 5 * time.Second} - _, e = client.Do(req) - if e != nil { - Log.Error("Error sending webhook", e) - } - }() -} diff --git a/log/notify.go b/log/notify.go new file mode 100644 index 0000000..d340110 --- /dev/null +++ b/log/notify.go @@ -0,0 +1,26 @@ +package log + +const ( + NotificationServiceTypeDiscord = "discord" +) + +func NewNotificationService(service string, url string) NotificationService { + var s NotificationService + + switch service { + case NotificationServiceTypeDiscord: + s = &DiscordNotification{} + default: + Logger.Info("Notifications disabled") + return nil + } + + Logger.Info("Notifications enabled", "service", service, "url", url) + s.Init(url) + return s +} + +type NotificationService interface { + Init(url string) + Emit(msg string) +} diff --git a/main.go b/main.go index ed612b0..aed4d5b 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,11 @@ package main import ( "errors" "fmt" + "log/slog" "net/http" "os" + "strings" + "text/template" "github.com/Fesaa/ical-merger/config" "github.com/Fesaa/ical-merger/ical" @@ -12,35 +15,126 @@ import ( "github.com/Fesaa/ical-merger/server" ) +const motd = ` +======================================= +Listen on: {{.Host}} +Broadcasting notifications to: {{if .Config.Notification.Service}}{{.Config.Notification.Service}} at {{.Config.Notification.Url}}{{else}}none{{end}} +Publishing: +{{- range .Config.Sources }} + {{.Name}}: {{$.Host}}/{{.EndPoint}}.ics +{{- end }} +======================================= +` + +const healthCheckEndpoint = "/health" + +func healthCheckCmd(c *config.Config) { + host := c.Hostname + ":" + c.Port + if c.Hostname == "" { + host = "localhost" + host + } + + // Check if server is up + resp, err := http.Get("http://" + host + healthCheckEndpoint) + if err != nil { + log.Logger.Error("Failed to health check", "error", err) + panic(err) + } + if resp.StatusCode != http.StatusOK { + log.Logger.Error("Health check failed", "status", resp.StatusCode) + panic(fmt.Errorf("health check failed with status %d", resp.StatusCode)) + } +} + func main() { - args := os.Args[1:] - log.Init(len(args) > 0 && args[0] == "-debug") + logLevel := os.Getenv("log_level") + configFile := os.Getenv("config_file") - c, e := config.LoadConfig("./config.yaml") + // Load config + c, e := config.LoadConfig(configFile) if e != nil { + slog.Error("Error loading config", "error", e) panic(e) } + host := c.Hostname + ":" + c.Port + + // Initialize logger + log.Init(logLevel, c.Notification) + + // Run health check if requested + if os.Args[1] == "-health" { + healthCheckCmd(c) + return + } - if c.WebHook == "" { - log.Log.Warn("No webhook configured, will not send alerts") + // Generate motd + motd, e := generateMotd(host, *c) + if e != nil { + slog.Error("Error generating motd", "error", e) + panic(e) } + fmt.Println(motd) + mux := newServerMux(c) + + // Start server + e = http.ListenAndServe(host, mux) + if errors.Is(e, http.ErrServerClosed) { + log.Logger.Info("Server died", "error", e) + } else { + log.Logger.Error("Failed to start server", "error", e) + panic(e) + } +} + +func newServerMux(c *config.Config) *http.ServeMux { mux := http.NewServeMux() + mux.HandleFunc(healthCheckEndpoint, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Add sources to server for _, s := range c.Sources { - log.Log.Debugf("Adding source %s", s.EndPoint) - handler := *server.NewServerHandler(ical.FromSource(s), c.WebHook) + log.Logger.Debug("Adding source", "source", s.EndPoint) + handler := *server.NewServerHandler(ical.FromSource(s)) handler.Bootstrap() mux.HandleFunc(fmt.Sprintf("/%s.ics", s.EndPoint), handler.IcsHandler) } - host := c.Hostname + ":" + c.Port - log.Log.Info("Starting server on", host) - e = http.ListenAndServe(host, mux) - if errors.Is(e, http.ErrServerClosed) { - log.Log.Info("Server died: ", e) - } else { - log.Log.Error("Failed to start server") - panic(e) + return mux +} + +// generateMotd generates a message of the day +func generateMotd(host string, conf config.Config) (string, error) { + var ( + err error + b strings.Builder + motdTmpl *template.Template + ) + + motdTmpl, err = template.New("motd").Parse(motd) + if err != nil { + log.Logger.Error("Failed to parse motd template", "error", err) + return "", err } + + if strings.HasPrefix(host, ":") { + host = "http://localhost" + host + } + + var data = struct { + Host string + Config config.Config + }{ + Host: host, + Config: conf, + } + + if err := motdTmpl.Execute(&b, data); err != nil { + log.Logger.Error("Failed to execute motd template", "error", err) + return "", err + } + + return b.String(), nil } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..eeaa6c6 --- /dev/null +++ b/main_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Fesaa/ical-merger/config" + "github.com/Fesaa/ical-merger/log" + ical "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const icsPrefix = ".ics" + +var now = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local) + +type TestSuite struct { + suite.Suite + + sourceCalServer *httptest.Server + + cals map[string]*ical.Calendar +} + +// newSourceCalendar creates a new source calendar with the given name, rules and modifiers +// this is used to create a source mock source calendar which will then be used in the testing +// of the merge process +func (s *TestSuite) newSourceCalendar(name string, rules []config.Rule, modifiers []config.Modifier) (*ical.Calendar, config.SourceInfo, error) { + cal, ok := s.cals[name] + if ok { + return cal, config.SourceInfo{}, fmt.Errorf("calendar already exists") + } + + s.cals[name] = ical.NewCalendar() + s.cals[name].SetName(name) + + return s.cals[name], config.SourceInfo{ + Name: name, + Url: s.sourceCalServer.URL + "/" + name + ".ics", + Rules: rules, + Modifiers: modifiers, + }, nil +} + +// newMockICalServer creates a new mock iCal server which will be used to serve the mock source calendars +func (s *TestSuite) newMockICalServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.URL.Path[len(r.URL.Path)-4:], icsPrefix) { + w.WriteHeader(http.StatusBadRequest) + return + } + + calName := r.URL.Path[1 : len(r.URL.Path)-4] + cal, ok := s.cals[calName] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + var buf bytes.Buffer + err := cal.SerializeTo(&buf) + require.NoError(s.T(), err) + _, err = w.Write(buf.Bytes()) + require.NoError(s.T(), err) + }) + return httptest.NewServer(mux) +} + +// Setup the test +func (s *TestSuite) SetupTest() { + s.cals = make(map[string]*ical.Calendar) + s.sourceCalServer = s.newMockICalServer() +} + +// Cleanup after the test +func (s *TestSuite) TearDownSuite() { + s.sourceCalServer.Close() +} + +// TestMergeCals tests the merging of calendars +func (s *TestSuite) TestMergeCals() { + // Create a new source calendar + cal1, cal1SI, err := s.newSourceCalendar("cal1", []config.Rule{}, []config.Modifier{}) + require.NoError(s.T(), err) + cal2, cal2SI, err := s.newSourceCalendar("cal2", []config.Rule{}, []config.Modifier{}) + require.NoError(s.T(), err) + + // Create events + for i := 0; i < 3; i++ { + n := fmt.Sprintf("event%d", i) + e := cal1.AddEvent(n) + e.SetSummary(n) + e.SetDescription(n) + e.SetStartAt(now.Add(24 * time.Hour).Add(time.Duration(6+i) * time.Hour)) + } + + // Create a merged calendar server + server := newTestCalServer("test", cal1SI, cal2SI) + defer server.Close() + // Fetch the calendar + actualCal, err := server.fetchCalendar() + require.NoError(s.T(), err) + require.NotNil(s.T(), actualCal) + assert.Len(s.T(), actualCal.Events(), len(cal1.Events())+len(cal2.Events())) + + for _, e := range actualCal.Events() { + assert.NotEmpty(s.T(), e.GetProperty(ical.ComponentPropertySummary).Value) + } +} + +func TestMain(t *testing.T) { + log.Init("ERROR", config.Notification{Service: "none"}) + suite.Run(t, new(TestSuite)) +} + +// ///////////////////////// +// Test Helper Functions // +// ///////////////////////// +type testCalServer struct { + *httptest.Server + Source config.Source +} + +func newTestCalServer(calName string, sources ...config.SourceInfo) testCalServer { + source := config.Source{ + Name: calName, + EndPoint: calName, + Info: sources, + } + mux := newServerMux(&config.Config{ + Sources: []config.Source{ + source, + }, + }) + server := httptest.NewServer(mux) + + return testCalServer{ + Server: server, + Source: source, + } +} + +func (s *testCalServer) fetchCalendar() (*ical.Calendar, error) { + resp, err := s.Server.Client().Get(s.Server.URL + "/" + s.Source.EndPoint + icsPrefix) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return ical.ParseCalendar(resp.Body) +} + +func (s *testCalServer) Close() { + s.Server.Close() +} diff --git a/server/main.go b/server/main.go index 688637a..487fd31 100644 --- a/server/main.go +++ b/server/main.go @@ -12,27 +12,26 @@ import ( ) type ServerHandler struct { - cal ical.CustomCalender - cache string - webhook_url string + cal ical.CustomCalender + cache string } -func NewServerHandler(source ical.CustomCalender, url string) *ServerHandler { - return &ServerHandler{cal: source, webhook_url: url} +func NewServerHandler(source ical.CustomCalender) *ServerHandler { + return &ServerHandler{cal: source} } func (sh *ServerHandler) updateCache() { now := time.Now() - log.Log.Info("One hour since last request, remerging ics files") - log.ToWebhook(sh.webhook_url, fmt.Sprintf("[%s] Invalidated cache, remerging ics files", sh.cal.GetSource().XWRName)) - cal, e := sh.cal.Merge(sh.webhook_url) + log.Logger.Info(fmt.Sprintf("[%s] heartbeat - remerging", sh.cal.GetSource().Name)) + log.Logger.Notify(fmt.Sprintf("[%s] Invalidated cache, remerging ics files", sh.cal.GetSource().Name)) + cal, e := sh.cal.Merge() if e != nil { - log.Log.Error("Error merging ical files", e) - log.ToWebhook(sh.webhook_url, fmt.Sprintf("[%s] Error merging ical files: "+e.Error(), sh.cal.GetSource().XWRName)) + log.Logger.Error("Error merging ical files", "error", e) + log.Logger.Notify(fmt.Sprintf("[%s] Error merging ical files: %s", sh.cal.GetSource().Name, e.Error())) return } sh.cache = cal.Serialize() - log.ToWebhook(sh.webhook_url, fmt.Sprintf("[%s] Merged ical files in "+time.Since(now).String(), sh.cal.GetSource().XWRName)) + log.Logger.Notify(fmt.Sprintf("[%s] Merged ical files in %s", sh.cal.GetSource().Name, time.Since(now).String())) } func (sh *ServerHandler) heartbeat() { @@ -54,6 +53,6 @@ func (sh *ServerHandler) IcsHandler(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } - log.Log.Info("Request took", time.Since(now).Milliseconds(), "ms") - log.ToWebhook(sh.webhook_url, fmt.Sprintf("[%s] Served ics file in "+time.Since(now).String(), sh.cal.GetSource().XWRName)) + log.Logger.Info("Request complete", "elapsed_ms", time.Since(now).Milliseconds()) + log.Logger.Notify(fmt.Sprintf("[%s] Served ics file in %s", sh.cal.GetSource().Name, time.Since(now).String())) }