From 4a8f33f61cc6acf9ed492842f6068bedc013943f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 17 Nov 2025 13:09:18 -0700 Subject: [PATCH] wip(sendsms): send to a CSV list --- cmd/sendsms/go.mod | 5 + cmd/sendsms/go.sum | 2 + cmd/sendsms/sendsms.go | 239 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 cmd/sendsms/go.mod create mode 100644 cmd/sendsms/go.sum create mode 100644 cmd/sendsms/sendsms.go diff --git a/cmd/sendsms/go.mod b/cmd/sendsms/go.mod new file mode 100644 index 0000000..f81d747 --- /dev/null +++ b/cmd/sendsms/go.mod @@ -0,0 +1,5 @@ +module example.com/sendsms + +go 1.24.4 + +require github.com/joho/godotenv v1.5.1 diff --git a/cmd/sendsms/go.sum b/cmd/sendsms/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/cmd/sendsms/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/cmd/sendsms/sendsms.go b/cmd/sendsms/sendsms.go new file mode 100644 index 0000000..1c45570 --- /dev/null +++ b/cmd/sendsms/sendsms.go @@ -0,0 +1,239 @@ +package main + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "flag" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "slices" + "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"` +} + +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 validateAndFormatNumber(number string) string { + switch len(number) { + case 10: + return "+1" + number + case 11: + if strings.HasPrefix(number, "1") { + return "+" + number + } + fmt.Printf("warning: 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) + return "" + default: + fmt.Printf("warning: 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 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 { + fmt.Printf("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 = validateAndFormatNumber(message.PhoneNumber) + if message.PhoneNumber == "" { + fmt.Printf("skipping row %d (no phone number): %s\n", rowIndex, strings.Join(rec, ",")) + continue + } + + messages = append(messages, message) + } + + return messages, nil +} + +func main() { + _ = godotenv.Load("./.env") + sender := &SMSSender{ + baseURL: os.Getenv("SMSGW_BASEURL"), + user: os.Getenv("SMSGW_USER"), + 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") + flag.Parse() + + file, err := os.Open(*csvFile) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %q could not be read\n", *csvFile) + os.Exit(1) + } + defer func() { + _ = file.Close() + }() + + csvr := csv.NewReader(file) + csvr.FieldsPerRecord = -1 + + messages, err := LaxParseCSV(csvr, csvFile) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + r := rand.New(rand.NewSource(37)) + 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, "# 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 { + 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))) + } + } +}