WIP: Twilio
This commit is contained in:
parent
63f295a41d
commit
1c0162c434
7 changed files with 159 additions and 13 deletions
11
cmd/serve.go
11
cmd/serve.go
|
@ -71,6 +71,9 @@ var flagsServe = append(
|
|||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
|
@ -151,6 +154,9 @@ func execServe(c *cli.Context) error {
|
|||
smtpServerListen := c.String("smtp-server-listen")
|
||||
smtpServerDomain := c.String("smtp-server-domain")
|
||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||
twilioAccount := c.String("twilio-account")
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioFromNumber := c.String("twilio-from-number")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||
|
@ -209,6 +215,8 @@ func execServe(c *cli.Context) error {
|
|||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") {
|
||||
return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number and base-url must also be set")
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
|
@ -308,6 +316,9 @@ func execServe(c *cli.Context) error {
|
|||
conf.SMTPServerListen = smtpServerListen
|
||||
conf.SMTPServerDomain = smtpServerDomain
|
||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||
conf.TwilioAccount = twilioAccount
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioFromNumber = twilioFromNumber
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||
|
|
|
@ -105,6 +105,9 @@ type Config struct {
|
|||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
TwilioAccount string
|
||||
TwilioAuthToken string
|
||||
TwilioFromNumber string
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
|
@ -183,6 +186,9 @@ func NewConfig() *Config {
|
|||
SMTPServerListen: "",
|
||||
SMTPServerDomain: "",
|
||||
SMTPServerAddrPrefix: "",
|
||||
TwilioAccount: "",
|
||||
TwilioAuthToken: "",
|
||||
TwilioFromNumber: "",
|
||||
MessageLimit: DefaultMessageLengthLimit,
|
||||
MinDelay: DefaultMinDelay,
|
||||
MaxDelay: DefaultMaxDelay,
|
||||
|
|
|
@ -106,6 +106,8 @@ var (
|
|||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
|
||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
|
||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
||||
errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: SMS and calling is disabled", "https://ntfy.sh/docs/publish/#sms", nil}
|
||||
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#sms", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
|
|
@ -20,6 +20,7 @@ const (
|
|||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagFileCache = "file_cache"
|
||||
tagMessageCache = "message_cache"
|
||||
tagStripe = "stripe"
|
||||
|
|
|
@ -98,6 +98,7 @@ var (
|
|||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`)
|
||||
|
||||
//go:embed site
|
||||
webFs embed.FS
|
||||
|
@ -668,7 +669,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
|
@ -722,6 +723,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||
if s.smtpSender != nil && email != "" {
|
||||
go s.sendEmail(v, m, email)
|
||||
}
|
||||
if s.config.TwilioAccount != "" && sms != "" {
|
||||
go s.sendSMS(v, r, m, sms)
|
||||
}
|
||||
if call != "" {
|
||||
go s.callPhone(v, r, m, call)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
|
@ -831,7 +838,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, sms, call string, unifiedpush bool, err *errHTTP) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
|
||||
|
@ -847,7 +854,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
|
@ -865,13 +872,25 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
sms = readParam(r, "x-sms", "sms")
|
||||
if sms != "" && s.config.TwilioAccount == "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
|
||||
} else if sms != "" && !phoneNumberRegex.MatchString(sms) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
if call != "" && s.config.TwilioAccount == "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
|
||||
} else if call != "" && !phoneNumberRegex.MatchString(call) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||
if messageStr != "" {
|
||||
|
@ -880,7 +899,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
for i, t := range m.Tags {
|
||||
|
@ -889,18 +908,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
if email != "" {
|
||||
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
}
|
||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||
if err != nil {
|
||||
return false, false, "", false, errHTTPBadRequestDelayCannotParse
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||
return false, false, "", false, errHTTPBadRequestDelayTooSmall
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||
return false, false, "", false, errHTTPBadRequestDelayTooLarge
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
|
@ -908,7 +927,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
|
@ -922,7 +941,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||
cache = false
|
||||
email = ""
|
||||
}
|
||||
return cache, firebase, email, unifiedpush, nil
|
||||
return cache, firebase, email, sms, call, unifiedpush, nil
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
|
|
|
@ -144,6 +144,12 @@
|
|||
# smtp-server-domain:
|
||||
# smtp-server-addr-prefix:
|
||||
|
||||
# If enabled, ntfy can send SMS text messages and do voice calls via Twilio, and the "X-SMS" and "X-Call" headers.
|
||||
#
|
||||
# twilio-account:
|
||||
# twilio-auth-token:
|
||||
# twilio-from-number:
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
#
|
||||
|
|
101
server/server_twilio.go
Normal file
101
server/server_twilio.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioMessageEndpoint = "Messages.json"
|
||||
twilioCallEndpoint = "Calls.json"
|
||||
twilioCallTemplate = `
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say>You have a message from notify on topic %s. Message:</Say>
|
||||
<Pause length="1"/>
|
||||
<Say>%s</Say>
|
||||
<Pause length="1"/>
|
||||
<Say>End message.</Say>
|
||||
<Pause length="1"/>
|
||||
<Say>%s</Say>
|
||||
<Pause length="1"/>
|
||||
</Response>`
|
||||
)
|
||||
|
||||
func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
|
||||
body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(m))
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioFromNumber)
|
||||
data.Set("To", to)
|
||||
data.Set("Body", body)
|
||||
s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data)
|
||||
}
|
||||
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
||||
body := fmt.Sprintf(twilioCallTemplate, m.Topic, m.Message, s.messageFooter(m))
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioFromNumber)
|
||||
data.Set("To", to)
|
||||
data.Set("Twiml", body)
|
||||
s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data)
|
||||
}
|
||||
|
||||
func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) {
|
||||
logContext := log.Context{
|
||||
"twilio_from": s.config.TwilioFromNumber,
|
||||
"twilio_to": to,
|
||||
}
|
||||
ev := logvrm(v, r, m).Tag(tagTwilio).Fields(logContext)
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_body", body).Trace("Sending Twilio request")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Sending Twilio request")
|
||||
}
|
||||
response, err := s.performTwilioRequestInternal(endpoint, data)
|
||||
if err != nil {
|
||||
ev.
|
||||
Field("twilio_body", body).
|
||||
Field("twilio_response", response).
|
||||
Err(err).
|
||||
Warn("Error sending Twilio request")
|
||||
return
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", response).Trace("Received successful Twilio response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio response")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) {
|
||||
requestURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/%s", s.config.TwilioAccount, endpoint)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
func (s *Server) messageFooter(m *message) string {
|
||||
topicURL := s.config.BaseURL + "/" + m.Topic
|
||||
sender := m.Sender.String()
|
||||
if m.User != "" {
|
||||
sender = fmt.Sprintf("%s (%s)", m.User, m.Sender)
|
||||
}
|
||||
return fmt.Sprintf("This message was sent by %s via %s", sender, util.ShortTopicURL(topicURL))
|
||||
}
|
Loading…
Reference in a new issue