diff --git a/.gitignore b/.gitignore index 66fd13c..a3adb86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,go,jetbrains+iml +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,go,jetbrains+iml + +### Go ### # Binaries for programs and plugins *.exe *.exe~ @@ -13,3 +18,160 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +### Go Patch ### +/vendor/ +/Godeps/ + +### JetBrains+iml ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains+iml Patch ### +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,go,jetbrains+iml \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100755 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100755 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..ef52f7f --- /dev/null +++ b/api.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +type slot struct { + StartDate time.Time +} + +func getCovidVaccinationSlots(authority int) ([]slot, error) { + url := fmt.Sprintf("https://e-gov.ooe.gv.at/at.gv.ooe.cip/services/api/covid/slots?page=1&size=1000&orgUnitId=%d&birthdate=1990-01-01", authority) + response, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("http get slots failed: %w", err) + } + defer response.Body.Close() + + var jsonSlots []map[string]interface{} + err = json.NewDecoder(response.Body).Decode(&jsonSlots) + + slots, err := parseSlots(jsonSlots) + if err != nil { + return nil, err + } + + return slots, err +} + +// startDate of slots doesn't follow the ISO 8601 definition so we need to parse the dates by ourselves. +func parseSlots(jsonSlots []map[string]interface{}) ([]slot, error) { + slots := make([]slot, len(jsonSlots)) + for _, jsonSlot := range jsonSlots { + startDate, err := time.ParseInLocation("2006-01-02T15:04:05", jsonSlot["startDate"].(string), time.Local) + if err != nil { + return nil, fmt.Errorf("parse time failed: %w", err) + } + slots = append(slots, slot{startDate}) + } + return slots, nil +} \ No newline at end of file diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..17ce0e5 --- /dev/null +++ b/flags.go @@ -0,0 +1,94 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "golang.org/x/term" + "strconv" + "strings" + "syscall" + "time" +) + +var ( + authorities []int + date time.Time + iftttEventName string + iftttKey string +) + +func parseFlags() error { + authoritiesFlag := flag.String("authorities", "", "comma-separated list of authorities") + dateFlag := flag.String("date", "", "date in ISO format, e.g. 2021-06-30") + iftttEventNameFlag := flag.String("ifttt-event-name", "", "IFTTT event name of webhook") + flag.Parse() + + if *authoritiesFlag == "" { + return errors.New("authorities flag missing") + } + + if *dateFlag == "" { + return errors.New("date flag missing") + } + + if *iftttEventNameFlag == "" { + return errors.New("ifttt-event-name flag missing") + } + + err := parseAuthoritiesFlag(authoritiesFlag) + if err != nil { + return fmt.Errorf("parse authorities failed: %w", err) + } + + err = parseDateFlag(dateFlag) + if err != nil { + return fmt.Errorf("parse date failed: %w", err) + } + + parseIFTTTEventName(iftttEventNameFlag) + + err = promptIFTTTKey() + if err != nil { + return fmt.Errorf("prompt for ifttt key failed: %w", err) + } + + return nil +} + +func parseAuthoritiesFlag(authoritiesFlag *string) error { + for _, authority := range strings.Split(*authoritiesFlag, ",") { + atoi, err := strconv.Atoi(authority) + if err != nil { + return fmt.Errorf("convert authority string to int failed: %w", err) + } + authorities = append(authorities, atoi) + } + + return nil +} + +func parseDateFlag(dateFlag *string) error { + var err error + date, err = time.Parse("2006-01-02", strings.TrimSpace(*dateFlag)) + if err != nil { + return fmt.Errorf("parse date failed: %w", err) + } + + return nil +} + +func parseIFTTTEventName(iftttEventNameFlag *string) { + iftttEventName = strings.TrimSpace(*iftttEventNameFlag) +} + +func promptIFTTTKey() error { + fmt.Println("Enter IFTTT Key: ") + iftttKeyBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("read password prompt failed: %w", err) + } + + iftttKey = string(iftttKeyBytes) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb40618 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module ooe-impf-alert + +go 1.16 + +require golang.org/x/term v0.0.0-20210503060354-a79de5458b56 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a488ebd --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9870f85 --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "flag" + "log" + "sort" + "time" +) + +func main() { + err := parseFlags() + if err != nil { + log.Println(err) + flag.Usage() + return + } + + for range time.Tick(10 * time.Second) { + checkForEarlierSlot() + } +} + +func checkForEarlierSlot() { + slots := getSlotsForAuthorities() + if len(slots) <= 0 { + log.Println("no slots found") + return + } + + earliestSlot := getEarliestSlot(slots) + + if earliestSlot.StartDate.Before(date) { + handleFoundSlot(earliestSlot) + return + } + + log.Printf("no earlier slot found. earliest slot on %s\n", earliestSlot.StartDate.Format(time.RFC822)) +} + +func getSlotsForAuthorities() []slot { + var slots []slot + for _, authority := range authorities { + slotsOfAuthority, err := getCovidVaccinationSlots(authority) + if err != nil { + log.Fatal(err) + } + + for _, slot := range slotsOfAuthority { + slots = append(slots, slot) + } + } + return slots +} + +func getEarliestSlot(slots []slot) slot { + sort.Slice(slots, func(i, j int) bool { + return slots[i].StartDate.Before(slots[j].StartDate) + }) + + return slots[0] +} + +func handleFoundSlot(earliestSlot slot) { + log.Printf("YEAHHH. found an earlier slot on %s\n", earliestSlot.StartDate.Format(time.RFC822)) + + err := sendPushNotification(earliestSlot.StartDate) + if err != nil { + log.Fatal(err) + } +} diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..d843bc0 --- /dev/null +++ b/notify.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type webhookBody struct { + Date string `json:"value1"` +} + +func sendPushNotification(date time.Time) error { + body := &webhookBody{date.Format(time.RFC822)} + + buf := bytes.NewBuffer(make([]byte, 0)) + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return fmt.Errorf("encode json failed: %w", err) + } + + url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", iftttEventName, iftttKey) + _, err = http.Post(url, "application/json", buf) + if err != nil { + return fmt.Errorf("http post request failed: %w", err) + } + + return nil +}