package main import ( "encoding/json" "flag" "fmt" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "time" "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"` } 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 logLevel := slog.LevelInfo if *verboseFlag { 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") } // Parse config file var config Config _, err := toml.DecodeFile(*configPath, &config) if err != nil { slog.Error("Failed to load config", "path", *configPath, "err", err) os.Exit(1) } slog.Debug("Loaded config", "Ntfy Channel", config.NtfyChannel) startDate := time.Now().Format(time.RFC3339) for _, doctor := range config.Doctors { hasAvailability, err := check(doctor, startDate, defaultDoctolibLimit) if err != nil { slog.Error("Failed to check Doctolib", "doctor", doctor.Name, "err", err) continue } if hasAvailability { slog.Info("DoctoCheck FOUND") msg := fmt.Sprintf("Availability found for %s, check Doctolib!", doctor.Name) err = ntfy(msg, config.NtfyChannel) if err != nil { slog.Error("Failed to send notification", "doctor", doctor.Name, "channel", config.NtfyChannel, "err", err) continue } } if *alwaysNotifyFlag && !hasAvailability { msg := fmt.Sprintf("[DEBUG] No availability found for %s", doctor.Name) err = ntfy(msg, config.NtfyChannel) if err != nil { slog.Error("Failed to send notification", "doctor", doctor.Name, "channel", config.NtfyChannel, "err", err) continue } } } }