diff --git a/cmd/serve.go b/cmd/serve.go index ab8d75ec..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" @@ -77,6 +78,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"}), @@ -187,6 +189,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") @@ -456,6 +459,13 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService + 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 02418b19..8a125146 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1261,10 +1261,85 @@ 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 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 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 +* `{{.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: + +=== "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" + 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! + + ``` + ## 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 8e7dcda2..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,6 +129,7 @@ type Config struct { TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string + TwilioCallFormat *template.Template MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -226,6 +228,7 @@ func NewConfig() *Config { TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", + TwilioCallFormat: nil, MessageSizeLimit: DefaultMessageSizeLimit, MessageDelayMin: DefaultMessageDelayMin, MessageDelayMax: DefaultMessageDelayMax, diff --git a/server/server.yml b/server/server.yml index d9e85453..639ed492 100644 --- a/server/server.yml +++ b/server/server.yml @@ -216,11 +216,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 is the custom TwiML send to the Call API (optional, 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..6a613d49 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,33 +4,49 @@ 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 ( - twilioCallFormat = ` +// 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(` - 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. Goodbye. -` -) +`)) + +// 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. @@ -65,7 +81,29 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { if u != nil { sender = u.Name } - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) + tmpl := defaultTwilioCallFormatTemplate + if s.config.TwilioCallFormat != nil { + tmpl = s.config.TwilioCallFormat + } + 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) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 2501916a..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) { @@ -202,6 +204,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 = template.Must(template.New("twiml").Parse(` + + + + Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht: + + {{.Message}} + + Ende der Nachricht. + + 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 + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + 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) + 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"