From 5fdb4746cc1318d4bc457804a3092ed3ab3b6633 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 23 Jan 2026 20:17:37 -0700 Subject: [PATCH] feat(sendsms): add --start-time, --stop-time, --max-duration, --min-delay --- cmd/sendsms/sendsms.go | 300 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 271 insertions(+), 29 deletions(-) diff --git a/cmd/sendsms/sendsms.go b/cmd/sendsms/sendsms.go index 28378bd..60a63e9 100644 --- a/cmd/sendsms/sendsms.go +++ b/cmd/sendsms/sendsms.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "slices" + "strconv" "strings" "time" @@ -39,7 +40,31 @@ type Payload struct { Priority int `json:"priority,omitempty"` } +var ErrInvalidClockFormat = fmt.Errorf("invalid clock time, ex: '06:00 PM', '6pm', or '18:00' (space and case insensitive)") +var ErrInvalidClockTime = fmt.Errorf("invalid hour or minute, for example '27:63 p' would not be valid") + +type MainConfig struct { + csvPath string + dryRun bool + shuffle bool + startClock string + startTime time.Time + endClock string + endTime time.Time + maxDuration time.Duration + duration time.Duration + minDelay time.Duration + maxDelay time.Duration + delay time.Duration + verbose bool +} + func main() { + var err error + cfg := MainConfig{ + maxDelay: 2 * time.Minute, + } + _ = godotenv.Load("./.env") sender := &SMSSender{ baseURL: os.Getenv("SMSGW_BASEURL"), @@ -47,49 +72,179 @@ func main() { password: os.Getenv("SMSGW_PASSWORD"), } - dryRun := flag.Bool("dry-run", false, "Print curl commands instead of sending messages") - csvFile := flag.String("csv", "./messages.csv", "Path to file with newline-delimited phone numbers") + // TODO add days of week + // TODO add start time zone and end time zone for whole country (e.g. 9am ET to 8pm PT) + now := time.Now() + zoneName, offset := now.Zone() + + flag.BoolVar(&cfg.dryRun, "dry-run", false, "Print curl commands instead of sending messages") + flag.StringVar(&cfg.csvPath, "csv", "./messages.csv", "Path to file with newline-delimited phone numbers") + flag.BoolVar(&cfg.shuffle, "shuffle", false, "Randomize the list") + flag.StringVar(&cfg.startClock, "start-time", "10am", "don't send messages before this time (e.g. 10:00, 10am, 00:00)") + flag.StringVar(&cfg.endClock, "end-time", "8:30pm", "don't send messages after this time (e.g. 4pm, 23:59)") + flag.DurationVar(&cfg.maxDuration, "max-duration", 0, "don't send messages for more than this long (e.g. 10m, 2h30m, 6h)") + flag.DurationVar(&cfg.minDelay, "min-delay", 0, "don't send messages closer together on average than this (e.g. 10s, 2m) (Default: 20s)") flag.Parse() - file, err := os.Open(*csvFile) + fmt.Fprintf(os.Stderr, "Current time zone: %s, Offset: %.2fh\n", zoneName, float64(offset)/3600) + // os.Exit(1) + + // now, startTime, and endTime checks + { + cfg.startTime, err = parseClock(cfg.startClock, now) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: could not use --start-time %q: %v\n", cfg.startClock, err) + os.Exit(1) + } + cfg.endTime, err = parseClock(cfg.endClock, now) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: could not use --end-time %q: %v\n", cfg.endClock, err) + os.Exit(1) + } + if cfg.startTime.After(cfg.endTime) || cfg.startTime.Equal(cfg.endTime) { + fmt.Fprintf(os.Stderr, + "Error: no time between --start-time %q and --end-time %q\n", + cfg.startTime, cfg.endTime, + ) + os.Exit(1) + } + } + + if cfg.minDelay == 0 { + cfg.minDelay = 20 * time.Second + } + if cfg.maxDelay < cfg.minDelay { + cfg.maxDelay = cfg.minDelay + } + + file, err := os.Open(cfg.csvPath) if err != nil { - fmt.Fprintf(os.Stderr, "error: %q could not be read\n", *csvFile) + fmt.Fprintf(os.Stderr, "Error: %q could not be read\n", cfg.csvPath) os.Exit(1) } defer func() { _ = file.Close() }() - csvr := csv.NewReader(file) csvr.FieldsPerRecord = -1 - - messages, err := LaxParseCSV(csvr, csvFile) + messages, err := cfg.LaxParseCSV(csvr, cfg.csvPath) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } + fmt.Fprintf(os.Stderr, "\nInfo: parsed %q\n\n", cfg.csvPath) + + if now.After(cfg.endTime) || now.Equal(cfg.endTime) { + fmt.Fprintf(os.Stderr, "Too late now. Waiting until tomorrow:\n") + + cfg.startTime = safeSetTomorrow(cfg.startTime) + cfg.endTime = safeSetTomorrow(cfg.endTime) + + // check for issues caused by daylight savings + if cfg.startTime.After(cfg.endTime) || cfg.startTime.Equal(cfg.endTime) { + fmt.Fprintf(os.Stderr, + "Error: no time between --start-time %q and --end-time %q\n", + cfg.startTime, cfg.endTime, + ) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "\t%s\n", cfg.startTime) + } else { + cfg.startTime = now + } + + { + // duration, delay + cfg.duration = cfg.endTime.Sub(cfg.startTime) + if cfg.maxDuration != 0 { + if cfg.maxDuration < cfg.duration { + cfg.duration = cfg.maxDuration + } + } + n := len(messages) + // add a small buffer so we complete in the time, even with randomness + n += 2 + cfg.delay = cfg.duration / time.Duration(n) + if cfg.delay < cfg.minDelay { + fmt.Fprintf(os.Stderr, "Warn: cannot send all %d messages in %s (would require 1 message every %s)\n", len(messages), cfg.duration, cfg.delay) + fmt.Fprintf(os.Stderr, " (we'll just send what we can for now, 1 every %s)\n", cfg.minDelay) + cfg.delay = cfg.minDelay + } + if cfg.delay > cfg.maxDelay { + cfg.delay = cfg.maxDelay + } + // add a small buffer to allow for a final message, with randomness + cfg.duration = cfg.duration + cfg.delay + cfg.delay + fmt.Fprintf(os.Stderr, "Info: sending for the next %s\n", cfg.duration.Round(time.Second)) + } + + // if there was a delay + diff := cfg.startTime.Sub(now) + time.Sleep(diff) r := rand.New(rand.NewSource(37)) - r.Shuffle(len(messages), func(i, j int) { - messages[i], messages[j] = messages[j], messages[i] - }) + if cfg.shuffle { + r.Shuffle(len(messages), func(i, j int) { + messages[i], messages[j] = messages[j], messages[i] + }) + } - for _, message := range messages { - delay := 60 + rand.Float64()*90 + fmt.Fprintf(os.Stderr, + "Info: sending %d messages, roughly 1 every %s\n", + len(messages), cfg.delay.Round(time.Second), + ) + quarterDelay := cfg.delay / 4 + baseDelay := quarterDelay * 3 + jitter := int64(quarterDelay * 2) + fmt.Fprintf(os.Stderr, + " (%s + %s jitter)\n", + baseDelay.Round(time.Millisecond), time.Duration(jitter).Round(time.Millisecond), + ) + + deadline := now.Add(cfg.duration) + if cfg.dryRun { + os.Exit(0) + } + for i, message := range messages { + now := time.Now() + if now.After(deadline) { + cur := i + 1 + last := len(messages) + left := last - cur + if left > 0 { + fmt.Printf("Oh, look at the time. Ending now. (%d messages remaining)\n", left) + return + } + } + + var delay time.Duration + { + ns := int64(baseDelay) + rand.Int63n(jitter) + delay = time.Duration(ns) + } + + if cfg.dryRun { + fmt.Printf("sleep %s\n\n", delay) + } else if i > 0 { + time.Sleep(delay) + } fmt.Fprintf(os.Stderr, "# Send to %s (%s) %s-%s\n", message.PhoneNumber[:2], message.PhoneNumber[2:5], message.PhoneNumber[5:8], message.PhoneNumber[8:]) text := strings.ReplaceAll(message.MessageTemplate, "{First}", message.FirstName) - if *dryRun { + if cfg.dryRun { sender.printDryRun(message.PhoneNumber, text) - fmt.Printf("sleep %.3f\n\n", delay) - } else { - sender.sendMessage(message.PhoneNumber, text) - fmt.Printf("sleep %.3f\n\n", delay) - time.Sleep(time.Duration(delay * float64(time.Second))) + continue } + sender.sendMessage(message.PhoneNumber, text) } } +// set by day rather than time to account for daylight savings +func safeSetTomorrow(ref time.Time) time.Time { + return time.Date(ref.Year(), ref.Month(), 1+ref.Day(), ref.Hour(), ref.Minute(), 0, 0, ref.Location()) +} + func cleanPhoneNumber(raw string) string { var cleaned strings.Builder for i, char := range raw { @@ -100,7 +255,7 @@ func cleanPhoneNumber(raw string) string { return cleaned.String() } -func validateAndFormatNumber(number string) string { +func (cfg *MainConfig) validateAndFormatNumber(number string) string { switch len(number) { case 10: return "+1" + number @@ -108,16 +263,22 @@ func validateAndFormatNumber(number string) string { if strings.HasPrefix(number, "1") { return "+" + number } - fmt.Printf("warning: invalid 11-digit number '%s'\n", number) + if cfg.verbose { + fmt.Fprintf(os.Stderr, "Warn: invalid 11-digit number '%s'\n", number) + } return "" case 12: if strings.HasPrefix(number, "+1") { return number } - fmt.Printf("warning: invalid 12-digit number '%s' does not start with +1\n", number) + if cfg.verbose { + fmt.Fprintf(os.Stderr, "Warn: invalid 12-digit number '%s' does not start with +1\n", number) + } return "" default: - fmt.Printf("warning: invalid number length for '%s'\n", number) + if cfg.verbose { + fmt.Fprintf(os.Stderr, "Warn: invalid number length for '%s'\n", number) + } return "" } } @@ -188,17 +349,17 @@ func GetFieldIndex(header []string, name string) int { return -1 } -func LaxParseCSV(csvr *csv.Reader, csvFile *string) ([]SMSMessage, error) { +func (cfg *MainConfig) LaxParseCSV(csvr *csv.Reader, csvFile string) ([]SMSMessage, error) { header, err := csvr.Read() if err != nil { - return nil, fmt.Errorf("error: %q header could not be parsed: %w", *csvFile, err) + return nil, fmt.Errorf("error: %q header could not be parsed: %w", csvFile, err) } FIELD_NICK := GetFieldIndex(header, "Preferred") FIELD_PHONE := GetFieldIndex(header, "Phone") FIELD_MESSAGE := GetFieldIndex(header, "Message") if FIELD_NICK == -1 || FIELD_PHONE == -1 || FIELD_MESSAGE == -1 { - return nil, fmt.Errorf("error: %q is missing one or more of 'Preferred', 'Phone', and/or 'Message'", *csvFile) + return nil, fmt.Errorf("error: %q is missing one or more of 'Preferred', 'Phone', and/or 'Message'", csvFile) } FIELD_MIN := 1 + slices.Max([]int{FIELD_NICK, FIELD_PHONE, FIELD_MESSAGE}) @@ -211,11 +372,13 @@ func LaxParseCSV(csvr *csv.Reader, csvFile *string) ([]SMSMessage, error) { break } if err != nil { - return nil, fmt.Errorf("error parsing %q row %d: %w", *csvFile, rowIndex, err) + return nil, fmt.Errorf("error parsing %q row %d: %w", csvFile, rowIndex, err) } if len(rec) < FIELD_MIN { - fmt.Printf("skipping row %d (too few fields): %s\n", rowIndex, strings.Join(rec, ",")) + if cfg.verbose { + fmt.Fprintf(os.Stderr, "Warn: skipping row %d (too few fields): %s\n", rowIndex, strings.Join(rec, ",")) + } continue } @@ -226,9 +389,11 @@ func LaxParseCSV(csvr *csv.Reader, csvFile *string) ([]SMSMessage, error) { } message.PhoneNumber = cleanPhoneNumber(message.PhoneNumber) - message.PhoneNumber = validateAndFormatNumber(message.PhoneNumber) + message.PhoneNumber = cfg.validateAndFormatNumber(message.PhoneNumber) if message.PhoneNumber == "" { - fmt.Printf("skipping row %d (no phone number): %s\n", rowIndex, strings.Join(rec, ",")) + if cfg.verbose { + fmt.Fprintf(os.Stderr, "Warn: skipping row %d (no phone number): %s\n", rowIndex, strings.Join(rec, ",")) + } continue } @@ -237,3 +402,80 @@ func LaxParseCSV(csvr *csv.Reader, csvFile *string) ([]SMSMessage, error) { return messages, nil } + +// parseClock parses "10am", "10:00", "22:30", etc. into today's date + that time +func parseClock(s string, ref time.Time) (t time.Time, err error) { + // "10:05 AM" => "10:05am" + // "10 AM" => "10am" + // "23:05" => "23:05" + // "00" => "00" + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, " ", "") + + var hour, min int + var ampm string + + // "10:05am" => "10:05" + // "10am" => "10" + // "23:05" => "23:05" + // "00" => "00" + if strings.HasSuffix(s, "am") { + ampm = "am" + s = strings.TrimSuffix(s, "am") + } else if strings.HasSuffix(s, "pm") { + ampm = "pm" + s = strings.TrimSuffix(s, "pm") + } + + // "10:05" => {hour: 10, minute: 5} + // "10" => {hour: 10, minute: 0} + // "00" => {hour: 0, minute: 0} + // "23:05" => "23:05" + // "00" => "00" + parts := strings.Split(s, ":") + switch len(parts) { + case 2: + minStr := parts[1] + minStr = strings.TrimLeft(minStr, "0") + if len(minStr) > 0 { + min, err = strconv.Atoi(minStr) + if err != nil { + return t, ErrInvalidClockFormat + } + } + fallthrough + case 1: + hourStr := parts[0] + hourStr = strings.TrimLeft(hourStr, "0") + if len(hourStr) > 0 { + hour, err = strconv.Atoi(hourStr) + if err != nil { + return t, ErrInvalidClockFormat + } + } + default: + return t, ErrInvalidClockFormat + } + + if hour < 0 || hour > 23 || min < 0 || min > 59 { + return t, ErrInvalidClockTime + } + + switch ampm { + case "pm": + if hour < 12 { + hour += 12 + } + case "am": + if hour == 12 { + hour = 0 + } + case "": + // no change + default: + panic(fmt.Errorf("impossible condition: ampm set to %q", ampm)) + } + + t = time.Date(ref.Year(), ref.Month(), ref.Day(), hour, min, 0, 0, ref.Location()) + return t, nil +}