Move Twilio to twilio/ package

This commit is contained in:
binwiederhier
2026-07-17 13:48:48 +02:00
parent 24bc50b585
commit ac63a2eea0
7 changed files with 588 additions and 191 deletions
+169
View File
@@ -0,0 +1,169 @@
// 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 err
}
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 err
}
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
}
+301
View File
@@ -0,0 +1,301 @@
package twilio
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"text/template"
"github.com/stretchr/testify/require"
)
func TestClient_Call_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
require.Equal(t, "ntfy/1.2.3", r.Header.Get("User-Agent"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "+1234567890", form.Get("From"))
require.Equal(t, "+11122233344", form.Get("To"))
require.Contains(t, form.Get("Twiml"), "You have a message from notify on topic mytopic. Message:")
require.Contains(t, form.Get("Twiml"), "hi there")
require.Contains(t, form.Get("Twiml"), "This message was sent by user phil.")
}
// TestClient_Call_EscapesXML ensures that user-controlled fields cannot break out of the
// TwiML document, i.e. that a message containing XML is escaped rather than interpreted
func TestClient_Call_EscapesXML(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
data := &CallData{
Topic: "mytopic",
Message: `</Say><Say>evil</Say>`,
Tags: []string{"<tag>"},
Sender: `phil & "friends"`,
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
twiml := form.Get("Twiml")
require.NotContains(t, twiml, "<Say>evil</Say>")
require.Contains(t, twiml, "&lt;/Say&gt;&lt;Say&gt;evil&lt;/Say&gt;")
require.Contains(t, twiml, "phil &amp; &#34;friends&#34;")
// The caller's data must not be modified by the escaping
require.Equal(t, `</Say><Say>evil</Say>`, data.Message)
require.Equal(t, []string{"<tag>"}, data.Tags)
}
func TestClient_Call_CustomCallFormat(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Message}} von {{.Sender}}</Say></Response>`))
c := NewClient(conf)
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>hi there von phil</Say></Response>", form.Get("Twiml"))
}
// TestClient_Call_RendersAllFields covers the fields that the default TwiML template does not
// use, i.e. Title, Priority and Tags, including the escaping of every tag
func TestClient_Call_RendersAllFields(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Title}}/{{.Priority}}{{range .Tags}}/{{.}}{{end}}</Say></Response>`))
c := NewClient(conf)
data := &CallData{
Topic: "mytopic",
Title: "a <title>",
Priority: 5,
Tags: []string{"<one>", "two & three"},
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>a &lt;title&gt;/5/&lt;one&gt;/two &amp; three</Say></Response>", form.Get("Twiml"))
}
func TestClient_Call_TemplateError(t *testing.T) {
conf := testConfig("http://dummy.invalid")
conf.CallFormat = template.Must(template.New("twiml").Parse(`{{.DoesNotExist}}`))
c := NewClient(conf)
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic"}))
}
// TestClient_Call_Created ensures that a 201 Created is treated as a success. The Twilio Calls
// API returns 201 (not 200) for a successfully queued call, so this must not be an error.
func TestClient_Call_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"queued"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error, so that the server counts it as a failure instead of a success. Twilio rejects calls
// with a 4xx, e.g. for an invalid "To" number, or when the account is out of funds.
func TestClient_Call_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":21211,"message":"Invalid 'To' Phone Number: +invalid"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Call("+invalid", &CallData{Topic: "mytopic", Message: "hi there"})
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Call_TwilioServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TransportError ensures that a call to an unreachable Twilio API returns an
// error, so that the server can count it as a failure
func TestClient_Call_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
func TestClient_Call_InvalidBaseURL(t *testing.T) {
c := NewClient(testConfig("://invalid"))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Verify_Created ensures that a 201 Created is treated as a success. The Twilio
// Verify API returns 201 (not 200) when it creates a verification, so this must not be an error.
func TestClient_Verify_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"pending"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
}
// TestClient_Verify_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error. Without this, no SMS is sent, but the user is still told to check their phone.
func TestClient_Verify_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":60200,"message":"Invalid parameter"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Verify("+12223334444", "sms")
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Verify_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_Verify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_CheckVerify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_Verify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/Verifications", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
require.Equal(t, "Channel=sms&To=%2B12223334444", body)
}
func TestClient_CheckVerify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/VerificationCheck", r.URL.Path)
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.CheckVerify("+12223334444", "123456"))
require.Equal(t, "Code=123456&To=%2B12223334444", body)
}
// TestClient_CheckVerify_Expired ensures that a 404 from the Twilio Verify API is
// mapped to ErrVerificationExpired, which the server turns into an HTTP 410
func TestClient_CheckVerify_Expired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.True(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_CheckVerify_OtherError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
// closedServerURL returns the URL of a server that is not listening anymore, to simulate an
// unreachable Twilio API
func closedServerURL(t *testing.T) string {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("Should not be called")
}))
server.Close()
return server.URL
}
func testConfig(baseURL string) *Config {
return &Config{
Account: "AC1234567890",
AuthToken: "AAEAA1234567890",
PhoneNumber: "+1234567890",
CallsBaseURL: baseURL,
VerifyBaseURL: baseURL,
VerifyService: "VA1234567890",
BuildVersion: "1.2.3",
}
}
+80
View File
@@ -0,0 +1,80 @@
package twilio
import (
"bytes"
"encoding/xml"
"errors"
"text/template"
)
// ErrVerificationExpired is returned by CheckVerify if the verification code has
// expired, or if it never existed in the first place
var ErrVerificationExpired = errors.New("phone number verification expired or does not exist")
// Config holds the Twilio configuration for the client
type Config struct {
Account string // Twilio account SID, e.g. AC123...
AuthToken string // Twilio auth token
PhoneNumber string // Twilio number to use for outgoing calls
CallsBaseURL string // Base URL of the Twilio Calls API
VerifyBaseURL string // Base URL of the Twilio Verify API
VerifyService string // Twilio Verify service ID, e.g. VA123...
CallFormat *template.Template // TwiML template for calls; if nil, defaultCallFormatTemplate is used
BuildVersion string // ntfy version, used for the User-Agent header
}
// defaultCallFormatTemplate is the default TwiML template used for Twilio calls.
// It can be overridden in the server configuration's twilio-call-format field.
//
// The format uses Go template syntax with the following fields:
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
// String fields are automatically XML-escaped.
var defaultCallFormatTemplate = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user {{.Sender}}. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`))
// CallData holds the data passed to the Twilio call format template. String fields are
// XML-escaped before the template is executed, so callers pass them unescaped.
type CallData struct {
Topic string
Title string
Message string
Priority int
Tags []string
Sender string
}
// escaped returns a copy of the call data with all string fields XML-escaped
func (d *CallData) escaped() *CallData {
tags := make([]string, len(d.Tags))
for i, tag := range d.Tags {
tags[i] = xmlEscapeText(tag)
}
return &CallData{
Topic: xmlEscapeText(d.Topic),
Title: xmlEscapeText(d.Title),
Message: xmlEscapeText(d.Message),
Priority: d.Priority,
Tags: tags,
Sender: xmlEscapeText(d.Sender),
}
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
}