From 5f59ecf4157f4d753c18b2292208ac2a6edc68fe Mon Sep 17 00:00:00 2001 From: Benjamin Sigonneau Date: Thu, 4 Jun 2026 17:30:54 +0200 Subject: [PATCH] Refactor: split in several files --- .gitignore | 1 + config.go | 34 +++++++++++ doctolib.go | 89 ++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 + main.go | 167 ++++++++++------------------------------------------ notify.go | 42 +++++++++++++ 7 files changed, 201 insertions(+), 136 deletions(-) create mode 100644 config.go create mode 100644 doctolib.go create mode 100644 notify.go diff --git a/.gitignore b/.gitignore index a761db0..ea06b27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea config.toml doctocheck +doctocheck-arm64 diff --git a/config.go b/config.go new file mode 100644 index 0000000..c49a473 --- /dev/null +++ b/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +const defaultDoctolibLimit = 5 + +type DoctorConfig struct { + Name string `toml:"name"` + MotiveId string `toml:"motive_id"` + AgendaId string `toml:"agenda_id"` + PracticeId string `toml:"practice_id"` +} + +type Config struct { + NtfyChannel string `toml:"ntfy_channel"` + Doctors []DoctorConfig `toml:"doctor"` +} + +func loadConfig(path string) (Config, error) { + var cfg Config + _, err := toml.DecodeFile(path, &cfg) + return cfg, err +} + +func defaultConfigFile() string { + cwd, _ := os.Getwd() + configPath := filepath.Join(cwd, "config.toml") + return configPath +} diff --git a/doctolib.go b/doctolib.go new file mode 100644 index 0000000..d3cec6a --- /dev/null +++ b/doctolib.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" +) + +type DoctoResp struct { + Availabilities []struct { + Date string `json:"date"` + Slots []string `json:"slots"` + } `json:"availabilities"` + Total int `json:"total"` + Reason string `json:"reason"` + Message string `json:"message"` + NextSlot string `json:"next_slot,omitempty"` +} + +type DoctoError struct { + Error []string `json:"error"` +} + +type DoctoClient struct { + httpClient *http.Client + limit int +} + +func NewDoctoClient() *DoctoClient { + return &DoctoClient{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + limit: defaultDoctolibLimit, + } +} + +func (docto *DoctoClient) check(doctor DoctorConfig, startDate string) (bool, error) { + params := url.Values{} + params.Set("visit_motive_id", doctor.MotiveId) + params.Set("agenda_ids", doctor.AgendaId) + params.Set("practice_ids", doctor.PracticeId) + params.Set("start_date_time", startDate) + params.Set("limit", fmt.Sprintf("%d", docto.limit)) + + u := url.URL{ + Scheme: "https", + Host: "www.doctolib.fr", + Path: "/search/availabilities.json", + RawQuery: params.Encode(), + } + queryUrl := u.String() + + slog.Debug("check", "Doctolib URL", queryUrl) + req, err := http.NewRequest(http.MethodGet, queryUrl, nil) + if err != nil { + return false, err + } + req.Header.Add("User-Agent", "Mozilla/5.0 (compatible; DoctoCheck/1.0)") + req.Header.Add("Referer", "https://www.doctolib.fr/search") + resp, err := docto.httpClient.Do(req) + if err != nil { + return false, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + var doctoError DoctoError + _ = json.NewDecoder(resp.Body).Decode(&doctoError) + return false, fmt.Errorf("unexpected status %d, message:%v", resp.StatusCode, doctoError.Error) + } + + var doctoResp DoctoResp + err = json.NewDecoder(resp.Body).Decode(&doctoResp) + if err != nil { + return false, err + } + + slog.Debug("Doctolib response", "Availabilities", doctoResp.Availabilities) + slog.Debug( + "Doctolib response", + "Total", doctoResp.Total, + "Reason", doctoResp.Reason, + "Message", doctoResp.Message, + "NextSlot", doctoResp.NextSlot) + hasAvailability := doctoResp.Total > 0 || doctoResp.NextSlot != "" + return hasAvailability, nil +} diff --git a/go.mod b/go.mod index 6bd6e7a..4b4da2c 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module doctocheck go 1.26.3 require github.com/BurntSushi/toml v1.6.0 + +require github.com/spf13/pflag v1.0.10 // indirect diff --git a/go.sum b/go.sum index f74b269..e984862 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/main.go b/main.go index 9658acd..2c4fbed 100644 --- a/main.go +++ b/main.go @@ -1,175 +1,70 @@ package main import ( - "encoding/json" - "flag" "fmt" "log/slog" - "net/http" - "net/url" "os" - "path/filepath" - "strings" "time" - "github.com/BurntSushi/toml" + "github.com/spf13/pflag" ) -const defaultDoctolibLimit = 5 - -type DoctorConfig struct { - Name string `toml:"name"` - MotiveId string `toml:"motive_id"` - AgendaId string `toml:"agenda_id"` - PracticeId string `toml:"practice_id"` +type CLIArgs struct { + Verbose bool + AlwaysNotify bool + ConfigPath string } -type Config struct { - NtfyChannel string `toml:"ntfy_channel"` - Doctors []DoctorConfig `toml:"doctor"` +func parseCliArgs() *CLIArgs { + var cfg CLIArgs + + // Define flags bound directly to the struct fields + pflag.BoolVarP(&cfg.Verbose, "verbose", "v", false, "Enable verbose (debug) logging") + pflag.BoolVarP(&cfg.AlwaysNotify, "always-notify", "a", false, "Always send a ntfy notification, even if no availability is found") + pflag.StringVarP(&cfg.ConfigPath, "config", "c", defaultConfigFile(), "Path to the JSON/YAML config file") + + pflag.Parse() + return &cfg } -type DoctoResp struct { - Availabilities []struct { - Date string `json:"date"` - Slots []string `json:"slots"` - } `json:"availabilities"` - Total int `json:"total"` - Reason string `json:"reason"` - Message string `json:"message"` - NextSlot string `json:"next_slot,omitempty"` -} - -type DoctoError struct { - Error []string `json:"error"` -} - -var httpClient = &http.Client{Timeout: 10 * time.Second} - -func check(doctor DoctorConfig, startDate string, limit int) (bool, error) { - params := url.Values{} - params.Set("visit_motive_id", doctor.MotiveId) - params.Set("agenda_ids", doctor.AgendaId) - params.Set("practice_ids", doctor.PracticeId) - params.Set("start_date_time", startDate) - params.Set("limit", fmt.Sprintf("%d", limit)) - - u := url.URL{ - Scheme: "https", - Host: "www.doctolib.fr", - Path: "/search/availabilities.json", - RawQuery: params.Encode(), - } - queryUrl := u.String() - - slog.Debug("check", "Doctolib URL", queryUrl) - req, err := http.NewRequest(http.MethodGet, queryUrl, nil) - if err != nil { - return false, err - } - req.Header.Add("User-Agent", "Mozilla/5.0 (compatible; DoctoCheck/1.0)") - req.Header.Add("Referer", "https://www.doctolib.fr/search") - resp, err := httpClient.Do(req) - if err != nil { - return false, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - var doctoError DoctoError - _ = json.NewDecoder(resp.Body).Decode(&doctoError) - return false, fmt.Errorf("unexpected status %d, message:%v", resp.StatusCode, doctoError.Error) - } - - var doctoResp DoctoResp - err = json.NewDecoder(resp.Body).Decode(&doctoResp) - if err != nil { - return false, err - } - - slog.Debug("Doctolib response", "Availabilities", doctoResp.Availabilities) - slog.Debug( - "Doctolib response", - "Total", doctoResp.Total, - "Reason", doctoResp.Reason, - "Message", doctoResp.Message, - "NextSlot", doctoResp.NextSlot) - hasAvailability := doctoResp.Total > 0 || doctoResp.NextSlot != "" - return hasAvailability, nil -} - -func ntfy(msg string, channel string) error { - ntfyUrl := fmt.Sprintf("https://ntfy.sh/%s", channel) - req, err := http.NewRequest(http.MethodPost, ntfyUrl, strings.NewReader(msg)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "text/plain") - req.Header.Set("Title", "DoctoCheck") - req.Header.Set("Tags", "medical_symbol") - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status %d", resp.StatusCode) - } - - return nil -} - -func main() { - fmt.Println("DoctoCheck") - - // Parse CLI args - verboseFlag := flag.Bool("v", false, "Enable verbose logging") - alwaysNotifyFlag := flag.Bool("always-notify", false, "Always send a ntfy notification") - configPath := flag.String("config", "", "path to config file") - flag.Parse() - - // Configure log handler, adjusting log level if needed +func configureLogger(verbose bool) { logLevel := slog.LevelInfo - if *verboseFlag { + if verbose { logLevel = slog.LevelDebug } logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, }) slog.SetDefault(slog.New(logHandler)) +} - // Default to working directory for config.toml, if not specified as a CLI arg - if *configPath == "" { - cwd, err := os.Getwd() - if err != nil { - slog.Error("Failed to get cwd", "err", err) - os.Exit(1) - } - *configPath = filepath.Join(cwd, "config.toml") - } +func main() { + cliArgs := parseCliArgs() + configureLogger(cliArgs.Verbose) // Parse config file - var config Config - _, err := toml.DecodeFile(*configPath, &config) + config, err := loadConfig(cliArgs.ConfigPath) if err != nil { - slog.Error("Failed to load config", "path", *configPath, "err", err) + slog.Error("Failed to load config", "config file", cliArgs.ConfigPath, "err", err) os.Exit(1) } slog.Debug("Loaded config", "Ntfy Channel", config.NtfyChannel) + // Check for availabilities + notifier := NewNotifier(config.NtfyChannel) + doctoClient := NewDoctoClient() startDate := time.Now().Format(time.RFC3339) for _, doctor := range config.Doctors { - hasAvailability, err := check(doctor, startDate, defaultDoctolibLimit) + hasAvailability, err := doctoClient.check(doctor, startDate) if err != nil { slog.Error("Failed to check Doctolib", "doctor", doctor.Name, "err", err) continue } if hasAvailability { - slog.Info("DoctoCheck FOUND") + slog.Info("DoctoCheck FOUND", "doctor", doctor.Name) msg := fmt.Sprintf("Availability found for %s, check Doctolib!", doctor.Name) - err = ntfy(msg, config.NtfyChannel) + err = notifier.Send(msg) if err != nil { slog.Error("Failed to send notification", "doctor", doctor.Name, @@ -179,9 +74,9 @@ func main() { } } - if *alwaysNotifyFlag && !hasAvailability { + if cliArgs.AlwaysNotify && !hasAvailability { msg := fmt.Sprintf("[DEBUG] No availability found for %s", doctor.Name) - err = ntfy(msg, config.NtfyChannel) + err = notifier.Send(msg) if err != nil { slog.Error("Failed to send notification", "doctor", doctor.Name, diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..0971562 --- /dev/null +++ b/notify.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +type Notifier struct { + httpClient *http.Client + channel string +} + +func NewNotifier(channel string) *Notifier { + return &Notifier{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + channel: channel, + } +} + +func (n *Notifier) Send(msg string) error { + ntfyUrl := fmt.Sprintf("https://ntfy.sh/%s", n.channel) + req, err := http.NewRequest(http.MethodPost, ntfyUrl, strings.NewReader(msg)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Title", "DoctoCheck") + req.Header.Set("Tags", "medical_symbol") + resp, err := n.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + return nil +}