golib/cmd/sendsms/sendsms.go

482 lines
13 KiB
Go

package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"flag"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type SMSSender struct {
baseURL string
user string
password string
}
type SMSMessage struct {
FirstName string
PhoneNumber string
MessageTemplate 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")
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"),
user: os.Getenv("SMSGW_USER"),
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.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, "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", cfg.csvPath)
os.Exit(1)
}
defer func() {
_ = file.Close()
}()
csvr := csv.NewReader(file)
csvr.FieldsPerRecord = -1
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))
if cfg.shuffle {
r.Shuffle(len(messages), func(i, j int) {
messages[i], messages[j] = messages[j], messages[i]
})
}
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 cfg.dryRun {
sender.printDryRun(message.PhoneNumber, text)
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 {
if (i == 0 && char == '+') || (char >= '0' && char <= '9') {
cleaned.WriteRune(char)
}
}
return cleaned.String()
}
func (cfg *MainConfig) validateAndFormatNumber(number string) string {
switch len(number) {
case 10:
return "+1" + number
case 11:
if strings.HasPrefix(number, "1") {
return "+" + 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
}
if cfg.verbose {
fmt.Fprintf(os.Stderr, "Warn: invalid 12-digit number '%s' does not start with +1\n", number)
}
return ""
default:
if cfg.verbose {
fmt.Fprintf(os.Stderr, "Warn: invalid number length for '%s'\n", number)
}
return ""
}
}
func (s *SMSSender) printDryRun(number, message string) {
url := s.baseURL + "/messages"
payload := Payload{
TextMessage: TextMessage{Text: message},
PhoneNumbers: []string{number},
Priority: 65,
}
body := bytes.NewBuffer(nil)
encoder := json.NewEncoder(body)
encoder.SetEscapeHTML(false)
_ = encoder.Encode(payload)
escapedBody := strings.ReplaceAll(body.String(), "'", "'\\''")
fmt.Printf("curl --fail-with-body --user '%s:%s' -X POST '%s' \\\n", s.user, s.password, url)
fmt.Printf(" -H 'Content-Type: application/json' \\\n")
fmt.Printf(" --data-binary '%s'\n", escapedBody)
}
func (s *SMSSender) sendMessage(number, message string) {
number = cleanPhoneNumber(number)
if len(number) == 0 {
panic(fmt.Errorf("non-sanitized number '%s'", number))
}
url := s.baseURL + "/messages"
payload := Payload{
TextMessage: TextMessage{Text: message},
PhoneNumbers: []string{number},
Priority: 65,
}
body := bytes.NewBuffer(nil)
encoder := json.NewEncoder(body)
encoder.SetEscapeHTML(false)
_ = encoder.Encode(payload)
req, _ := http.NewRequest("POST", url, body)
req.SetBasicAuth(s.user, s.password)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("error: failed to send message to '%s': %v\n", number, err)
return
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("error: failed to send message to '%s': %d %s\n", number, resp.StatusCode, string(body))
}
}
func GetFieldIndex(header []string, name string) int {
for i, h := range header {
if strings.EqualFold(strings.TrimSpace(h), name) {
return i
}
}
return -1
}
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)
}
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)
}
FIELD_MIN := 1 + slices.Max([]int{FIELD_NICK, FIELD_PHONE, FIELD_MESSAGE})
var messages []SMSMessage
rowIndex := 1 // 1-index, start at header
for {
rowIndex++
rec, err := csvr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("error parsing %q row %d: %w", csvFile, rowIndex, err)
}
if len(rec) < FIELD_MIN {
if cfg.verbose {
fmt.Fprintf(os.Stderr, "Warn: skipping row %d (too few fields): %s\n", rowIndex, strings.Join(rec, ","))
}
continue
}
message := SMSMessage{
FirstName: strings.TrimSpace(rec[FIELD_NICK]),
PhoneNumber: strings.TrimSpace(rec[FIELD_PHONE]),
MessageTemplate: strings.TrimSpace(rec[FIELD_MESSAGE]),
}
message.PhoneNumber = cleanPhoneNumber(message.PhoneNumber)
message.PhoneNumber = cfg.validateAndFormatNumber(message.PhoneNumber)
if message.PhoneNumber == "" {
if cfg.verbose {
fmt.Fprintf(os.Stderr, "Warn: skipping row %d (no phone number): %s\n", rowIndex, strings.Join(rec, ","))
}
continue
}
messages = append(messages, message)
}
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
}