Merge branch 'feature/twilio-call-format-file'

This commit is contained in:
binwiederhier
2026-01-17 05:00:03 -05:00
7 changed files with 208 additions and 18 deletions

View File

@@ -14,6 +14,7 @@ import (
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"text/template"
"time" "time"
"github.com/urfave/cli/v2" "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-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-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-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-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.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"}), 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") twilioAuthToken := c.String("twilio-auth-token")
twilioPhoneNumber := c.String("twilio-phone-number") twilioPhoneNumber := c.String("twilio-phone-number")
twilioVerifyService := c.String("twilio-verify-service") twilioVerifyService := c.String("twilio-verify-service")
twilioCallFormat := c.String("twilio-call-format")
messageSizeLimitStr := c.String("message-size-limit") messageSizeLimitStr := c.String("message-size-limit")
messageDelayLimitStr := c.String("message-delay-limit") messageDelayLimitStr := c.String("message-delay-limit")
totalTopicLimit := c.Int("global-topic-limit") totalTopicLimit := c.Int("global-topic-limit")
@@ -456,6 +459,13 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService 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.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit conf.TotalTopicLimit = totalTopicLimit

View File

@@ -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-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `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-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 ...`), 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. 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: |
<Response>
<Pause length="1"/>
<Say loop="3">
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 }}
<break time="1s"/>
{{ if neq .Title "" }}
Bro, it's titled: {{.Title}}.
{{ end }}
<break time="1s"/>
{{.Message}}
<break time="1s"/>
That is all.
<break time="1s"/>
You know who this message is from? It is from {{.Sender}}.
<break time="3s"/>
</Say>
<Say>See ya!</Say>
</Response>
```
=== "Custom TwiML (German)"
``` yaml
twilio-account: "AC12345beefbeef67890beefbeef122586"
twilio-auth-token: "affebeef258625862586258625862586"
twilio-phone-number: "+18775132586"
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
twilio-call-format: |
<Response>
<Pause length="1"/>
<Say loop="3" voice="alice" language="de-DE">
Du hast eine Nachricht zum Thema {{.Topic}}.
{{ if eq .Priority 5 }}
Achtung. Die Nachricht ist sehr wichtig.
{{ end }}
<break time="1s"/>
{{ if neq .Title "" }}
Titel der Nachricht: {{.Title}}.
{{ end }}
<break time="1s"/>
Nachricht:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
Ende der Nachricht.
<break time="1s"/>
Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
<break time="3s"/>
</Say>
<Say voice="alice" language="de-DE">Alla mol!</Say>
</Response>
```
## Message limits ## Message limits
There are a few message limits that you can configure: There are a few message limits that you can configure:

View File

@@ -1603,10 +1603,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Features:** **Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) * 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),
([#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)
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) * 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)
for the initial implementation)
### ntfy Android app v1.22.x (UNRELEASED) ### ntfy Android app v1.22.x (UNRELEASED)

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"io/fs" "io/fs"
"net/netip" "net/netip"
"text/template"
"time" "time"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
@@ -128,6 +129,7 @@ type Config struct {
TwilioCallsBaseURL string TwilioCallsBaseURL string
TwilioVerifyBaseURL string TwilioVerifyBaseURL string
TwilioVerifyService string TwilioVerifyService string
TwilioCallFormat *template.Template
MetricsEnable bool MetricsEnable bool
MetricsListenHTTP string MetricsListenHTTP string
ProfileListenHTTP string ProfileListenHTTP string
@@ -226,6 +228,7 @@ func NewConfig() *Config {
TwilioPhoneNumber: "", TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "", TwilioVerifyService: "",
TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit, MessageSizeLimit: DefaultMessageSizeLimit,
MessageDelayMin: DefaultMessageDelayMin, MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax, MessageDelayMax: DefaultMessageDelayMax,

View File

@@ -216,11 +216,13 @@
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 # - 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-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-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-account:
# twilio-auth-token: # twilio-auth-token:
# twilio-phone-number: # twilio-phone-number:
# twilio-verify-service: # twilio-verify-service:
# twilio-call-format:
# Interval in which keepalive messages are sent to the client. This is to prevent # Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity. # intermediaries closing the connection for inactivity.

View File

@@ -4,33 +4,49 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"text/template"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
) )
const ( // defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
twilioCallFormat = ` // 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(`
<Response> <Response>
<Pause length="1"/> <Pause length="1"/>
<Say loop="3"> <Say loop="3">
You have a message from notify on topic %s. Message: You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/> <break time="1s"/>
%s {{.Message}}
<break time="1s"/> <break time="1s"/>
End of message. End of message.
<break time="1s"/> <break time="1s"/>
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. To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/> <break time="3s"/>
</Say> </Say>
<Say>Goodbye.</Say> <Say>Goodbye.</Say>
</Response>` </Response>`))
)
// 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 // 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. // 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 { if u != nil {
sender = u.Name 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 := url.Values{}
data.Set("From", s.config.TwilioPhoneNumber) data.Set("From", s.config.TwilioPhoneNumber)
data.Set("To", to) data.Set("To", to)

View File

@@ -1,14 +1,16 @@
package server package server
import ( import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic" "sync/atomic"
"testing" "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) { 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(`
<Response>
<Pause length="1"/>
<Say language="de-DE" loop="3">
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
Ende der Nachricht.
<break time="1s"/>
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.
<break time="3s"/>
</Say>
<Say language="de-DE">Auf Wiederhören.</Say>
</Response>`))
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) { func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "http://dummy.invalid" c.TwilioCallsBaseURL = "http://dummy.invalid"