golib/cmd/sendsms/sendsms.go

620 lines
17 KiB
Go

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
printCurl 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"
fgGreen = "\033[32m"
fgRed = "\033[31m"
textErr = textBold + fgRed
textWarn = textBold + fgYellow
textInfo = fgYellow
textTmpl = fgGreen
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, "Skip sending messages and sleeping, runs without confirmation")
flag.BoolVar(&cfg.printCurl, "print-curl", false, "Show full curl commands instead of 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 missing or invalid data\n", textWarn, textReset, len(warns))
if !cfg.verbose {
fmt.Fprintf(os.Stderr, " (pass --verbose for more detail)\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", textTmpl, messages[0].Template, textReset)
fmt.Fprintf(os.Stderr, " %s%s%s\n", textInfo, messages[0].Text, textReset)
fmt.Fprintf(os.Stderr, "\n")
if !cfg.confirmed && !cfg.dryRun {
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 i > 0 {
if cfg.dryRun || cfg.printCurl {
fmt.Printf("sleep %s\n\n", delay.Round(time.Millisecond))
} else {
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.printCurl {
curl := sender.CurlString(message.Number, message.Text)
fmt.Println(curl)
} else {
fmt.Fprintf(os.Stderr, "%s\n", message.Text)
}
if cfg.dryRun {
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, "\nInfo: 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
}