package main import ( "bufio" "encoding/csv" "flag" "fmt" "io" "maps" "math/rand" "os" "regexp" "slices" "strconv" "strings" "time" "github.com/joho/godotenv" ) type SMSSender interface { CurlString(to, text string) string Send(to, text string) error } type SMSMessage struct { Name string Number string Template string Vars map[string]string Text string } type TextMessage struct { Text string `json:"text"` } type Payload struct { TextMessage TextMessage `json:"textMessage"` PhoneNumbers []string `json:"phoneNumbers"` 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") var ErrPhoneEmpty = fmt.Errorf("no phone number") var ErrPhoneInvalid11 = fmt.Errorf("invalid 11-digit number (does not start with 1)") var ErrPhoneInvalid12 = fmt.Errorf("invalid 12-digit number (does not start with +1)") var ErrPhoneInvalidLength = fmt.Errorf("invalid number length (should be 10 digits or 12 with +1 prefix)") type MainConfig struct { csvPath string dryRun bool shuffle bool startClock string startTime time.Time runTime time.Time endClock string endTime time.Time maxDuration time.Duration duration time.Duration minDelay time.Duration maxDelay time.Duration delay time.Duration verbose bool confirmed bool } const ( textReset = "\033[0m" textBold = "\033[1m" fgYellow = "\033[33m" fgBlue = "\033[34m" fgRed = "\033[31m" textErr = textBold + fgRed textWarn = textBold + fgYellow textInfo = fgYellow textPrompt = fgBlue ) func main() { var err error cfg := MainConfig{ maxDelay: 2 * time.Minute, } _ = godotenv.Load("./.env") // note: we could also use twilio, or whatever var sender SMSSender = &SMSGatewayForAndroid{ baseURL: os.Getenv("SMSGW_BASEURL"), username: os.Getenv("SMSGW_USERNAME"), password: os.Getenv("SMSGW_PASSWORD"), } // 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.confirmed, "y", false, "Confirm without prompting") flag.BoolVar(&cfg.verbose, "verbose", false, "Show parse warnings and other debug info") 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() fmt.Fprintf(os.Stderr, "Info: 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, "\n%sError%s: could not use --start-time %q: %v\n", textErr, textReset, cfg.startClock, err) os.Exit(1) } cfg.endTime, err = parseClock(cfg.endClock, now) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError%s: could not use --end-time %q: %v\n", textErr, textReset, cfg.endClock, err) os.Exit(1) } if cfg.startTime.After(cfg.endTime) || cfg.startTime.Equal(cfg.endTime) { fmt.Fprintf(os.Stderr, "\n%sError%s: no time between --start-time %q and --end-time %q\n", textErr, textReset, 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 } fmt.Fprintf(os.Stderr, "Info: opening, reading, and parsing %q\n", cfg.csvPath) file, err := os.Open(cfg.csvPath) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError%s: %v\n", textErr, textReset, err) os.Exit(1) } defer func() { _ = file.Close() }() csvr := csv.NewReader(file) csvr.FieldsPerRecord = -1 messages, warns, err := cfg.LaxParseCSV(csvr) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError%s: %v\n", textErr, textReset, err) os.Exit(1) } if len(warns) > 0 { fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "%sWarning%s: skipped %d rows with too few fields, invalid numbers, bad templates, etc\n", textWarn, textReset, len(warns)) if !cfg.verbose { fmt.Fprintf(os.Stderr, " (pass --verbose to show warnings)\n") } if cfg.verbose { for _, warn := range warns { fmt.Fprintf(os.Stderr, " Skip: %s\n", warn.Message) } } fmt.Fprintf(os.Stderr, "\n") } fmt.Fprintf(os.Stderr, "Info: list of %d messages\n", len(messages)) if now.After(cfg.endTime) || now.Equal(cfg.endTime) { fmt.Fprintf(os.Stderr, "%sWarning%s: Too late now. %sWaiting until tomorrow%s:\n", textWarn, textReset, textWarn, textReset) 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, "\n%sError%s: no time between --start-time %q and --end-time %q\n", textErr, textReset, cfg.startTime, cfg.endTime, ) os.Exit(1) } fmt.Fprintf(os.Stderr, " %s\n\n", cfg.startTime) } if now.Before(cfg.startTime) { fmt.Fprintf(os.Stderr, "\n%sWarning%s: It's too early now. %sWaiting until %s.%s\n\n", textWarn, textReset, textWarn, cfg.startTime.Format("3:04pm"), textReset) } cfg.runTime = now if cfg.startTime.After(now) { cfg.runTime = cfg.startTime } // duration { cfg.duration = cfg.endTime.Sub(cfg.runTime) if cfg.maxDuration != 0 { if cfg.maxDuration < cfg.duration { cfg.duration = cfg.maxDuration } } var startAgo = now.Sub(cfg.startTime) if startAgo >= 0 { fmt.Fprintf(os.Stderr, "Info: start after %s (%s ago)\n", cfg.startTime.Format("3:04pm"), startAgo.Round(time.Second)) } else { startAgo *= -1 fmt.Fprintf(os.Stderr, "Info: start after %s (%s from now)\n", cfg.startTime.Format("3:04pm"), startAgo.Round(time.Second)) } fmt.Fprintf(os.Stderr, "Info: end around %s (%s from now)\n", cfg.endTime.Format("3:04pm"), cfg.duration.Round(time.Second)) } // delay { 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, "\n") fmt.Fprintf(os.Stderr, "%sWarning%s: cannot send all %d messages in %s (would require 1 message every %s)\n", textWarn, textReset, len(messages), cfg.duration.Round(time.Second), cfg.delay.Round(time.Millisecond)) fmt.Fprintf(os.Stderr, " (we'll just %ssend what we can%s for now, 1 every %s)\n", textWarn, textReset, cfg.minDelay) fmt.Fprintf(os.Stderr, "\n") 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 } r := rand.New(rand.NewSource(37)) if cfg.shuffle { r.Shuffle(len(messages), func(i, j int) { messages[i], messages[j] = messages[j], messages[i] }) } quarterDelay := cfg.delay / 4 baseDelay := quarterDelay * 3 jitter := int64(quarterDelay * 2) fmt.Fprintf(os.Stderr, "Info: delay %s between messages (%s + %s jitter)\n", cfg.delay.Round(time.Second), baseDelay.Round(time.Millisecond), time.Duration(jitter).Round(time.Millisecond), ) if len(messages) == 0 { fmt.Fprintf(os.Stderr, "\n%sError%s: no messages to send\n", textErr, textReset) os.Exit(1) } fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "Info: This is what a %ssample message%s from list look like:\n", textInfo, textReset) fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, " To: %s (%s)\n", messages[0].Number, messages[0].Name) fmt.Fprintf(os.Stderr, " %s%s%s\n", textInfo, messages[0].Text, textReset) if !cfg.confirmed && !cfg.dryRun { fmt.Fprintf(os.Stderr, "\n") if !confirmContinue() { fmt.Fprintf(os.Stderr, "%scanceled%s\n", textErr, textReset) os.Exit(1) return } } // if there was a delay diff := cfg.startTime.Sub(now) if diff > 0 { fmt.Fprintf(os.Stderr, "\n%sWarning%s: It's too early now. %sWaiting until %s.%s\n", textWarn, textReset, textWarn, cfg.startTime.Format("3:04pm"), textReset) time.Sleep(diff) fmt.Fprintf(os.Stderr, "\n") } deadline := now.Add(cfg.duration) 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.Fprintf(os.Stderr, "\n%sError%s: Oh, look at the time. Ending now. (%d messages remaining)\n", textErr, textReset, left) os.Exit(1) 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.Number[:2], message.Number[2:5], message.Number[5:8], message.Number[8:]) if cfg.dryRun { fmt.Println(message.Text) // curl := sender.CurlString(message.Number, message.Text) // fmt.Println(curl) continue } if err := sender.Send(message.Number, message.Text); err != nil { fmt.Fprintf(os.Stderr, "%sError%s: %v\n", textErr, textReset, err) continue } } fmt.Fprintf(os.Stderr, "Info: finished at %s\n", time.Now()) } func confirmContinue() bool { reader := bufio.NewReader(os.Stdin) for { fmt.Printf(textPrompt + "Continue? [y/N] " + textReset) input, err := reader.ReadString('\n') if err != nil { return false // EOF or error → treat as no } input = strings.TrimSpace(strings.ToLower(input)) switch input { case "y", "yes": return true case "", "n", "no": return false default: fmt.Fprintf(os.Stderr, "%sError%s: please answer y or n", textErr, textReset) // loop again } } } // 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()) } // we're not just skipping symbols, // we're also eliminating non-printing characters copied from HTML and such func cleanPhoneNumber(raw string) string { var cleaned strings.Builder for i, char := range raw { if (i == 0 && char == '+') || (char >= '0' && char <= '9') { cleaned.WriteRune(char) } } return cleaned.String() } func (cfg *MainConfig) validateAndFormatNumber(number string) (string, error) { switch len(number) { case 0: return "", ErrPhoneEmpty case 10: return "+1" + number, nil case 11: if strings.HasPrefix(number, "1") { return "+" + number, nil } return "", fmt.Errorf("%w: %s", ErrPhoneInvalid11, number) case 12: if strings.HasPrefix(number, "+1") { return number, nil } return "", fmt.Errorf("%w: %s", ErrPhoneInvalid12, number) default: return "", fmt.Errorf("%w: %s", ErrPhoneInvalidLength, number) } } func GetFieldIndex(header []string, name string) int { for i, h := range header { if strings.EqualFold(strings.TrimSpace(h), name) { return i } } return -1 } type CSVWarn struct { Index int Code string Message string Record []string } func (w CSVWarn) Error() string { return w.Message } var reUnmatchedVars = regexp.MustCompile(`(\{[^}]+\})`) func (cfg *MainConfig) LaxParseCSV(csvr *csv.Reader) (messages []SMSMessage, warns []CSVWarn, err error) { header, err := csvr.Read() if err != nil { return nil, nil, fmt.Errorf("header could not be parsed: %w", err) } FIELD_NAME := GetFieldIndex(header, "Name") FIELD_PHONE := GetFieldIndex(header, "Phone") FIELD_MESSAGE := GetFieldIndex(header, "Message") if FIELD_NAME == -1 || FIELD_PHONE == -1 || FIELD_MESSAGE == -1 { return nil, nil, fmt.Errorf("header is missing one or more of 'Name', 'Phone', and/or 'Message'") } FIELD_MIN := 1 + slices.Max([]int{FIELD_NAME, FIELD_PHONE, FIELD_MESSAGE}) rowIndex := 1 // 1-index, start at header for { rowIndex++ rec, err := csvr.Read() if err == io.EOF { break } if err != nil { return nil, nil, fmt.Errorf("failed to parse row %d (and all following rows): %w", rowIndex, err) } if len(rec) < FIELD_MIN { warns = append(warns, CSVWarn{ Index: rowIndex, Code: "TooFewFields", Message: fmt.Sprintf("ignoring row %d: too few fields (want %d, have %d)", rowIndex, FIELD_MIN, len(rec)), Record: rec, }) continue } vars := make(map[string]string) n := min(len(header), len(rec)) for i := range n { switch i { case FIELD_NAME, FIELD_PHONE, FIELD_MESSAGE: continue default: key := header[i] val := rec[i] vars[key] = val } } message := SMSMessage{ Name: strings.TrimSpace(rec[FIELD_NAME]), Number: strings.TrimSpace(rec[FIELD_PHONE]), Template: strings.TrimSpace(rec[FIELD_MESSAGE]), Vars: vars, Text: strings.TrimSpace(rec[FIELD_MESSAGE]), } message.Text = replaceVar(message.Template, "Name", message.Name) keyIter := maps.Keys(message.Vars) keys := slices.Sorted(keyIter) for _, key := range keys { val := message.Vars[key] message.Text = replaceVar(message.Text, key, val) } if tmpls := reUnmatchedVars.FindAllString(message.Text, -1); len(tmpls) != 0 { return nil, nil, &CSVWarn{ Index: rowIndex, Code: "UnmatchedVars", Message: fmt.Sprintf( "failing due to row %d (%s): leftover template variable(s): %s", rowIndex, message.Name, strings.Join(tmpls, " "), ), Record: rec, } } message.Number = cleanPhoneNumber(message.Number) message.Number, err = cfg.validateAndFormatNumber(message.Number) if err != nil { warns = append(warns, CSVWarn{ Index: rowIndex, Code: "PhoneInvalid", Message: fmt.Sprintf("ignoring row %d (%s): %s", rowIndex, message.Name, err.Error()), Record: rec, }) continue } messages = append(messages, message) } return messages, warns, nil } func replaceVar(text, key, val string) string { if val != "" { // No special treatment: // "Hey {+Name}," => "Hey Doe," // "Bob,{Name}" => "Bob,Doe" // "{Name-},Joe" => "Doe,Joe" // "Hi {-Name-}, Joe" => "Hi Doe, Joe" var reHasVar = regexp.MustCompile(fmt.Sprintf(`\{\+?%s-?\}`, regexp.QuoteMeta(key))) return reHasVar.ReplaceAllString(text, val) } var metaKey = regexp.QuoteMeta(key) // "Hey {+Name}," => "Hey ," var reEatNone = regexp.MustCompile(fmt.Sprintf(`\{\+%s\}`, metaKey)) text = reEatNone.ReplaceAllString(text, val) // "Bob,{Name};" => "Bob;" var reEatOneLeft = regexp.MustCompile(fmt.Sprintf(`.?\{%s\}`, metaKey)) text = reEatOneLeft.ReplaceAllString(text, val) // ",{Name-};Joe" => ",Joe" var reEatOneRight = regexp.MustCompile(fmt.Sprintf(`\{%s-\}.?`, metaKey)) text = reEatOneRight.ReplaceAllString(text, val) // "Hi {-Name-}, Joe" => "Hi Joe" var reEatOneBoth = regexp.MustCompile(fmt.Sprintf(`.?\{-%s-\}.?`, metaKey)) text = reEatOneBoth.ReplaceAllString(text, val) return text } // 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 }