package main import ( // Standard library "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( // The default API endpoint used for sending messages. upstreamEndpoint = "https://sms.aa.net.uk/sms.cgi" // The maximum number of message parts we'll attempt to send at any one time. defaultMessageLimit = 3 // The maximum amount of time any HTTP request might take to complete. defaultHTTPTimeout = 15 * time.Second ) // A Provider represents any method for sending and receiving messages from a remote source. type Provider interface { SendMessage(User, Message) error } // AndrewsArnoldProvider is a message [Provider] that implements support for the Andrews & Arnold // SMS API. type AndrewsArnoldProvider struct { messageLimit int // The maximum number of message parts we'll attempt to send. reportURI string // The URI used for requesting a delivery report. client *http.Client // The underlying HTTP client to use in making requests to A&A. } // NewAndrewsArnoldProvider returns a [Provider] that can be used in sending and receiving messages // over the Andrews & Arnold SMS API. func NewAndrewsArnoldProvider(options ...ProviderOption) (*AndrewsArnoldProvider, error) { var p = &AndrewsArnoldProvider{ messageLimit: defaultMessageLimit, client: &http.Client{ Timeout: defaultHTTPTimeout, }, } for _, fn := range options { if err := fn(p); err != nil { return nil, err } } return p, nil } type ProviderOption func(*AndrewsArnoldProvider) error func WithReportHandler(hostname string) ProviderOption { return func(p *AndrewsArnoldProvider) error { if hostname == "" { return fmt.Errorf("cannot use report handler with empty hostname") } p.reportURI = hostname + "/%s/sms/srr?code=%%code&da=%%da&oa=%%oa&stanzaId=%s" return nil } } // SendMessage attempts to send the given [Message], authenticating as the given source [User]. // Messages may be of a size up to the message limit defined at the [AndrewsArnoldProvider] level, // and may fail to send otherwise. If the [Message.ID] field is set, a delivery report will be // requested for the given message. func (p *AndrewsArnoldProvider) SendMessage(user User, message Message) error { if user.tel == "" || user.password == "" { return fmt.Errorf("cannot send message for incomplete user information") } else if message.Dest == "" { return fmt.Errorf("cannot send message for empty destination number") } else if message.Body == "" { return nil } data := url.Values{ "username": {user.tel}, "password": {user.password}, "da": {message.Dest}, "ud": {message.Body}, "limit": {strconv.Itoa(p.messageLimit)}, "costcentre": {"sgx-aa"}, } // Send delivery report if a (presumably unique) message ID was given. // TODO: Maybe pull the report URI from an attached "delibery report handler". if message.ID != "" && p.reportURI != "" { data.Add("srr", fmt.Sprintf(p.reportURI, user.Token(), url.QueryEscape(message.ID))) } req, err := http.NewRequest("POST", upstreamEndpoint, strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("failed preparing HTTP request for message: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("failed sending HTTP request for message: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) if err != nil { return err } if msg, ok := strings.CutPrefix(string(body), "ERR:"); ok { return fmt.Errorf("failed sending HTTP request for message: %s", msg) } else if resp.StatusCode >= 400 { return fmt.Errorf("failed sending HTTP request for message: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) } return nil }