From 950ba1e2e1b6f38beffa2b5c85077e92deb52a74 Mon Sep 17 00:00:00 2001 From: Michael Nowak Date: Tue, 11 Mar 2025 09:45:32 +0000 Subject: [PATCH 1/4] Add optional twilio-call-format config option To be able to set custom TwiML send to the Call API. --- docs/config.md | 26 +++++++++++++++ server/config.go | 2 ++ server/server.yml | 2 ++ server/server_twilio.go | 6 +++- server/server_twilio_test.go | 61 ++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 9479301a..eb63b947 100644 --- a/docs/config.md +++ b/docs/config.md @@ -996,6 +996,32 @@ are the easiest), and then configure the following options: After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. +To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). +This is the default TwiML: + +``` xml + + + + You have a message from notify on topic %s. Message: + + %s + + End of message. + + This message was sent by user %s. It will be repeated three times. + To unsubscribe from calls like this, remove your phone number in the notify web app. + + + Goodbye. +` +``` + +The TwiML is internaly used as a format string: +1. The first `%s` will be replaced with the topic. +1. The second `%s` will be replaced with the message. +1. The third `%s` will be replaced with the message`s sender name. + ## Message limits There are a few message limits that you can configure: diff --git a/server/config.go b/server/config.go index 7267ce9d..9db41db0 100644 --- a/server/config.go +++ b/server/config.go @@ -120,6 +120,7 @@ type Config struct { TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string + TwilioCallFormat string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -212,6 +213,7 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", + TwilioCallFormat: "", MessageSizeLimit: DefaultMessageSizeLimit, MessageDelayMin: DefaultMessageDelayMin, MessageDelayMax: DefaultMessageDelayMax, diff --git a/server/server.yml b/server/server.yml index 7329d37e..4664ae8b 100644 --- a/server/server.yml +++ b/server/server.yml @@ -171,11 +171,13 @@ # - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 # - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 # - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +# - twilio-call-format (optional) is the custom TwiML send to the Call API. See: https://www.twilio.com/docs/voice/twiml # # twilio-account: # twilio-auth-token: # twilio-phone-number: # twilio-verify-service: +# twilio-call-format: # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. diff --git a/server/server_twilio.go b/server/server_twilio.go index 9a8ef8ad..37015dd6 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -14,7 +14,7 @@ import ( ) const ( - twilioCallFormat = ` + defaultTwilioCallFormat = ` @@ -65,6 +65,10 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } + twilioCallFormat := defaultTwilioCallFormat + if len(s.config.TwilioCallFormat) > 0 { + twilioCallFormat = s.config.TwilioCallFormat + } body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) data := url.Values{} data.Set("From", s.config.TwilioPhoneNumber) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 89a36051..70b06314 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -202,6 +202,67 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { }) } +func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + 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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioPhoneNumber = "+1234567890" + c.TwilioCallFormat = ` + + + + Du hast eine Nachricht von notify im Thema %s. Nachricht: + + %s + + Ende der Nachricht. + + Diese Nachricht wurde von Benutzer %s gesendet. Sie wird drei Mal wiederholt. + Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app. + + + Auf Wiederhören. +` + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "http://dummy.invalid" From 16900d2c109596bf546e47b5fa04a4ca9209ff95 Mon Sep 17 00:00:00 2001 From: Michael Nowak Date: Mon, 16 Jun 2025 15:14:13 +0200 Subject: [PATCH 2/4] Set twilio-call-format config option in serve command --- cmd/serve.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 62e0a14a..4b988adc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -172,6 +172,7 @@ func execServe(c *cli.Context) error { twilioAuthToken := c.String("twilio-auth-token") twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") + twilioCallFormat := c.String("twilio-call-format") messageSizeLimitStr := c.String("message-size-limit") messageDelayLimitStr := c.String("message-delay-limit") totalTopicLimit := c.Int("global-topic-limit") @@ -388,6 +389,7 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService + conf.TwilioCallFormat = twilioCallFormat conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit From 6bacf7dafc8b21f589af2ae1b983f6195a3096fc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 04:34:32 -0500 Subject: [PATCH 3/4] Works --- cmd/serve.go | 1 + docs/config.md | 62 ++++++++++++++++++++++++++++------------- go.sum | 16 ----------- server/server.yml | 2 +- server/server_twilio.go | 62 ++++++++++++++++++++++++++++++++++------- 5 files changed, 97 insertions(+), 46 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index f6eb1475..e4f432d2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -77,6 +77,7 @@ var flagsServe = append( 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-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), 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"}), diff --git a/docs/config.md b/docs/config.md index 56c2ceb8..0b961b1f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1261,30 +1261,54 @@ are the easiest), and then configure the following options: * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 * `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +* `twilio-call-format` is the custom TwiML send to the Call API (optional, see [TwiML](https://www.twilio.com/docs/voice/twiml)) After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. -To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). -This is the default TwiML: +To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is +rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message: -``` xml - - - - You have a message from notify on topic %s. Message: - - %s - - End of message. - - This message was sent by user %s. It will be repeated three times. - To unsubscribe from calls like this, remove your phone number in the notify web app. - - - Goodbye. -` -``` +* `{{.Topic}}` is the topic name +* `{{.Message}}` is the message body +* `{{.Title}}` is the message title +* `{{.Tags}}` is a list of tags +* `{{.Priority}}` is the message priority +* `{{.Sender}}` is the IP address or username of the sender + +Here's an example: + +=== English example + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Du hast eine Nachricht zum Thema {{.Topic}}. + {{ if eq .Priority 5 }} + Achtung. Die Nachricht ist sehr wichtig. + {{ end }} + + {{ if neq .Title "" }} + Titel der Nachricht: {{.Title}}. + {{ end }} + + Nachricht: + + {{.Message}} + + Ende der Nachricht. + + Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt. + + + Alla mol! + + ``` The TwiML is internaly used as a format string: 1. The first `%s` will be replaced with the topic. diff --git a/go.sum b/go.sum index 87ba62a2..77e16c2b 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,8 @@ cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute v1.53.0 h1:dILGanjePNsYfZVYYv6K0d4+IPnKX1gn84Fk8jDPNvs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo= -cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= @@ -21,8 +18,6 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7 cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= -cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= -cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= @@ -101,8 +96,6 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= -github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= @@ -270,23 +263,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= -google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4= -google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE= google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY= -google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss= google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= diff --git a/server/server.yml b/server/server.yml index ccab7dea..639ed492 100644 --- a/server/server.yml +++ b/server/server.yml @@ -216,7 +216,7 @@ # - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 # - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 # - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -# - twilio-call-format (optional) is the custom TwiML send to the Call API. See: https://www.twilio.com/docs/voice/twiml +# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml) # # twilio-account: # twilio-auth-token: diff --git a/server/server_twilio.go b/server/server_twilio.go index 37015dd6..0c5694d8 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,27 +4,35 @@ import ( "bytes" "encoding/xml" "fmt" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io" "net/http" "net/url" "strings" + "text/template" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) const ( + // defaultTwilioCallFormat is the default TwiML format 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. defaultTwilioCallFormat = ` - You have a message from notify on topic %s. Message: + You have a message from notify on topic {{.Topic}}. Message: - %s + {{.Message}} End of message. - This message was sent by user %s. It will be repeated three times. + 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. @@ -32,6 +40,16 @@ const ( ` ) +// twilioCallData holds the data passed to the Twilio call format template +type twilioCallData struct { + Topic string + Title string + Message string + Priority int + Tags []string + Sender string +} + // convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified // phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. // If the user is anonymous, it will return an error. @@ -65,11 +83,35 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } - twilioCallFormat := defaultTwilioCallFormat - if len(s.config.TwilioCallFormat) > 0 { - twilioCallFormat = s.config.TwilioCallFormat + templateStr := defaultTwilioCallFormat + if s.config.TwilioCallFormat != "" { + templateStr = s.config.TwilioCallFormat } - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) + tmpl, err := template.New("twiml").Parse(templateStr) + if err != nil { + logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error parsing Twilio call format template") + minc(metricCallsMadeFailure) + return + } + tags := make([]string, len(m.Tags)) + for i, tag := range m.Tags { + tags[i] = xmlEscapeText(tag) + } + templateData := &twilioCallData{ + Topic: xmlEscapeText(m.Topic), + Title: xmlEscapeText(m.Title), + Message: xmlEscapeText(m.Message), + Priority: m.Priority, + Tags: tags, + Sender: xmlEscapeText(sender), + } + var bodyBuf bytes.Buffer + if err := tmpl.Execute(&bodyBuf, templateData); err != nil { + logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template") + minc(metricCallsMadeFailure) + return + } + body := bodyBuf.String() data := url.Values{} data.Set("From", s.config.TwilioPhoneNumber) data.Set("To", to) From b23f6632b1230ceeec9f13cb4141053da0a432c0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 17 Jan 2026 04:59:46 -0500 Subject: [PATCH 4/4] Use Go templates, update docs --- cmd/serve.go | 9 +++++++- docs/config.md | 41 +++++++++++++++++++++++++++++------- docs/releases.md | 7 +++--- server/config.go | 5 +++-- server/server_twilio.go | 30 ++++++++++---------------- server/server_twilio_test.go | 20 ++++++++++-------- 6 files changed, 69 insertions(+), 43 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index e4f432d2..4d2803d5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -14,6 +14,7 @@ import ( "os/signal" "strings" "syscall" + "text/template" "time" "github.com/urfave/cli/v2" @@ -458,7 +459,13 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService - conf.TwilioCallFormat = twilioCallFormat + if twilioCallFormat != "" { + tmpl, err := template.New("twiml").Parse(twilioCallFormat) + if err != nil { + return fmt.Errorf("failed to parse twilio-call-format template: %w", err) + } + conf.TwilioCallFormat = tmpl + } conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit diff --git a/docs/config.md b/docs/config.md index 0b961b1f..8a125146 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1261,12 +1261,12 @@ are the easiest), and then configure the following options: * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 * `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -* `twilio-call-format` is the custom TwiML send to the Call API (optional, see [TwiML](https://www.twilio.com/docs/voice/twiml)) +* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional) After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. -To customize your message send to Twilio's Call API, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is +To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message: * `{{.Topic}}` is the topic name @@ -1278,7 +1278,37 @@ rendered as a [Go template](https://pkg.go.dev/text/template), so you can use th Here's an example: -=== English example +=== "Custom TwiML (English)" + ``` yaml + twilio-account: "AC12345beefbeef67890beefbeef122586" + twilio-auth-token: "affebeef258625862586258625862586" + twilio-phone-number: "+18775132586" + twilio-verify-service: "VA12345beefbeef67890beefbeef122586" + twilio-call-format: | + + + + Yo yo yo, you should totally check out this message for {{.Topic}}. + {{ if eq .Priority 5 }} + It's really really important, dude. So listen up! + {{ end }} + + {{ if neq .Title "" }} + Bro, it's titled: {{.Title}}. + {{ end }} + + {{.Message}} + + That is all. + + You know who this message is from? It is from {{.Sender}}. + + + See ya! + + ``` + +=== "Custom TwiML (German)" ``` yaml twilio-account: "AC12345beefbeef67890beefbeef122586" twilio-auth-token: "affebeef258625862586258625862586" @@ -1310,11 +1340,6 @@ Here's an example: ``` -The TwiML is internaly used as a format string: -1. The first `%s` will be replaced with the topic. -1. The second `%s` will be replaced with the message. -1. The third `%s` will be replaced with the message`s sender name. - ## Message limits There are a few message limits that you can configure: diff --git a/docs/releases.md b/docs/releases.md index 2f3f669e..4c950b3b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1603,10 +1603,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) - ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), - [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) - for the initial implementation) +* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), + [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) +* Support for a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) ### ntfy Android app v1.22.x (UNRELEASED) diff --git a/server/config.go b/server/config.go index 927c6b5a..c4c76bd1 100644 --- a/server/config.go +++ b/server/config.go @@ -3,6 +3,7 @@ package server import ( "io/fs" "net/netip" + "text/template" "time" "heckel.io/ntfy/v2/user" @@ -128,7 +129,7 @@ type Config struct { TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string - TwilioCallFormat string + TwilioCallFormat *template.Template MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -227,7 +228,7 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", - TwilioCallFormat: "", + TwilioCallFormat: nil, MessageSizeLimit: DefaultMessageSizeLimit, MessageDelayMin: DefaultMessageDelayMin, MessageDelayMax: DefaultMessageDelayMax, diff --git a/server/server_twilio.go b/server/server_twilio.go index 0c5694d8..6a613d49 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -15,14 +15,13 @@ import ( "heckel.io/ntfy/v2/util" ) -const ( - // defaultTwilioCallFormat is the default TwiML format 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. - defaultTwilioCallFormat = ` +// defaultTwilioCallFormatTemplate 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 defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(` @@ -37,8 +36,7 @@ const ( Goodbye. -` -) +`)) // twilioCallData holds the data passed to the Twilio call format template type twilioCallData struct { @@ -83,15 +81,9 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } - templateStr := defaultTwilioCallFormat - if s.config.TwilioCallFormat != "" { - templateStr = s.config.TwilioCallFormat - } - tmpl, err := template.New("twiml").Parse(templateStr) - if err != nil { - logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error parsing Twilio call format template") - minc(metricCallsMadeFailure) - return + tmpl := defaultTwilioCallFormatTemplate + if s.config.TwilioCallFormat != nil { + tmpl = s.config.TwilioCallFormat } tags := make([]string, len(m.Tags)) for i, tag := range m.Tags { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index c1418de1..9b6dcff5 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -1,14 +1,16 @@ package server import ( - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io" "net/http" "net/http/httptest" "sync/atomic" "testing" + "text/template" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { @@ -222,22 +224,22 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioPhoneNumber = "+1234567890" - c.TwilioCallFormat = ` + c.TwilioCallFormat = template.Must(template.New("twiml").Parse(` - Du hast eine Nachricht von notify im Thema %s. Nachricht: + Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht: - %s + {{.Message}} Ende der Nachricht. - Diese Nachricht wurde von Benutzer %s gesendet. Sie wird drei Mal wiederholt. + Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt. Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app. Auf Wiederhören. -` +`)) s := newTestServer(t, c) // Add tier and user @@ -246,7 +248,7 @@ func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) { MessageLimit: 10, CallLimit: 1, })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) u, err := s.userManager.User("phil") require.Nil(t, err)