// 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 }