mirror of
https://github.com/therootcompany/golib.git
synced 2025-11-20 05:54:29 +00:00
wip(sendsms): send to a CSV list
This commit is contained in:
parent
8155fa44fe
commit
4a8f33f61c
5
cmd/sendsms/go.mod
Normal file
5
cmd/sendsms/go.mod
Normal 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
2
cmd/sendsms/go.sum
Normal 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
239
cmd/sendsms/sendsms.go
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user