mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Move Twilio to twilio/ package
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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, "</Say><Say>evil</Say>")
|
||||
require.Contains(t, twiml, "phil & "friends"")
|
||||
// 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 <title>/5/<one>/two & 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",
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user