wip(sendsms): send to a CSV list

This commit is contained in:
AJ ONeal 2025-11-17 13:09:18 -07:00
parent 8155fa44fe
commit 4a8f33f61c
No known key found for this signature in database
3 changed files with 246 additions and 0 deletions

5
cmd/sendsms/go.mod Normal file
View File

@ -0,0 +1,5 @@
module example.com/sendsms
go 1.24.4
require github.com/joho/godotenv v1.5.1

2
cmd/sendsms/go.sum Normal file
View File

@ -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=

239
cmd/sendsms/sendsms.go Normal file
View File

@ -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)))
}
}
}