Files

170 lines
6.2 KiB
Go

// Package twilio talks to the Twilio API to make phone calls (for the "Call" feature) and to
// verify phone numbers. It holds the Twilio configuration, so that this functionality is
// decoupled from the ntfy server.
package twilio
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (
tagTwilio = "twilio"
)
// Client is the Twilio API client
type Client struct {
config *Config
}
// NewClient creates a new Twilio Client with the given config
func NewClient(config *Config) *Client {
return &Client{config: config}
}
// Call calls the Twilio API to make a phone call to the given phone number, using the given data
func (c *Client) Call(to string, data *CallData) error {
tmpl := defaultCallFormatTemplate
if c.config.CallFormat != nil {
tmpl = c.config.CallFormat
}
var bodyBuf bytes.Buffer
if err := tmpl.Execute(&bodyBuf, data.escaped()); err != nil {
log.Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
return err
}
body := bodyBuf.String()
form := url.Values{}
form.Set("From", c.config.PhoneNumber)
form.Set("To", to)
form.Set("Twiml", body)
ev := log.Tag(tagTwilio).
Field("twilio_to", to).
FieldIf("twilio_body", body, log.TraceLevel).
Debug("Sending Twilio request")
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.CallsBaseURL, c.config.Account)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
return err
} else if !success(code) {
// Twilio rejects calls with a 4xx, e.g. for an invalid phone number, or if the account
// is out of funds. Without this check, a rejected call would be counted as a success.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio call failed with status code %d", code)
return fmt.Errorf("twilio call failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
return nil
}
// Verify calls the Twilio Verify API to send a verification code to the given phone
// number, via the given channel ("sms" or "call")
func (c *Client) Verify(phoneNumber, channel string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.VerifyBaseURL, c.config.VerifyService)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
} else if !success(code) {
// Without this check, a rejected verification would look like a success to the caller,
// and the user would be told to wait for an SMS that was never sent.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio phone verification request failed with status code %d", code)
return fmt.Errorf("twilio phone verification request failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
// CheckVerify calls the Twilio Verify API to check the verification code for the given
// phone number. It returns ErrVerificationExpired if the code has expired or never existed.
func (c *Client) CheckVerify(phoneNumber, code string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.VerifyBaseURL, c.config.VerifyService)
req, err := c.newRequest(requestURL, form)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return ErrVerificationExpired
}
return fmt.Errorf("twilio phone verification failed with status code %d", resp.StatusCode)
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
// request POSTs the given form to the given Twilio API URL, and returns the raw response body
// and status code. It does not treat a non-2xx status code as an error; that is up to the
// caller. The response body is returned even if the request failed, so that it can be logged.
func (c *Client) request(requestURL string, form url.Values) (string, int, error) {
req, err := c.newRequest(requestURL, form)
if err != nil {
return "", 0, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", resp.StatusCode, err
}
return string(response), resp.StatusCode, nil
}
// success reports whether the given HTTP status code indicates success. Note that the Twilio
// Calls API returns 201 Created (not 200 OK) for a successfully queued call.
func success(code int) bool {
return code >= 200 && code <= 299
}
// newRequest creates a form-encoded POST request against the Twilio API, with the auth and
// User-Agent headers set
func (c *Client) newRequest(requestURL string, form url.Values) (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.Account, c.config.AuthToken))
return req, nil
}