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"