mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
170 lines
6.2 KiB
Go
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
|
|
}
|