diff --git a/docs/publish.md b/docs/publish.md
index 8e155491..54d2642c 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -341,7 +341,7 @@ Here's an **excerpt of emojis** I've found very useful in alert messages:
Tag
Emoji
-
+1
👍️
+
+1
👍
partying_face
🥳
tada
🎉
heavy_check_mark
✔️
@@ -789,96 +789,198 @@ The JSON message format closely mirrors the format of the message you can consum
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields:
-| Field | Required | Type | Example | Description |
-|------------|----------|----------------------------------|--------------------------------|-----------------------------------------------------------------------|
-| `topic` | ✔️ | *string* | `topic1` | Target topic name |
-| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
-| `title` | - | *string* | `Some title` | Message [title](#message-title) |
-| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
-| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
-| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
-| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
-| `filename` | - | *string* | `file.jpg` | File name of the attachment |
-| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
-| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
+| Field | Required | Type | Example | Description |
+|------------|----------|----------------------------------|---------------------------------------|-----------------------------------------------------------------------|
+| `topic` | ✔️ | *string* | `topic1` | Target topic name |
+| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
+| `title` | - | *string* | `Some title` | Message [title](#message-title) |
+| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
+| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
+| `actions` | - | *JSON array* | *(see [user actions](#user-actions))* | Custom [user action buttons](#user-actions) for notifications |
+| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
+| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
+| `filename` | - | *string* | `file.jpg` | File name of the attachment |
+| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
+| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
-## Click action
-You can define which URL to open when a notification is clicked. This may be useful if your notification is related
-to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
-the web browser (or the app) and open the website.
+## User actions
+You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
+useful and has countless applications. As of today, the following actions are supported:
-Here's an example that will open Reddit when the notification is clicked:
+* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
+* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
+ when the action button is tapped
+* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
+
+Here's an example of what that a notification with actions can look like:
+
+
=== "Command line (curl)"
```
- curl \
- -d "New messages on Reddit" \
- -H "Click: https://www.reddit.com/message/messages" \
- ntfy.sh/reddit_alerts
- ```
-
-=== "ntfy CLI"
- ```
- ntfy publish \
- --click="https://www.reddit.com/message/messages" \
- reddit_alerts "New messages on Reddit"
+ curl ntfy.sh \
+ -d '{
+ "topic": "myhome",
+ "message": "You seem to have left the house. Want to turn down the A/C?",
+ "actions": [
+ {
+ "action": "view",
+ "label": "Open portal",
+ "url": "https://home.nest.com/"
+ },
+ {
+ "action": "http",
+ "label": "Turn down",
+ "method": "POST",
+ "url": "https://developer-api.nest.com/devices/thermostats/XZA124D",
+ "headers": {
+ "Authorization": "Bearer ...",
+ "Content-Type": "application/json"
+ },
+ "body": "{\"target_temperature_f\": 65}"
+ },
+ {
+ "action": "broadcast",
+ "label": "Enter deep sleep 💤",
+ "extras": {
+ "command": "deepsleep"
+ }
+ }
+ ]
+ }'
```
=== "HTTP"
``` http
- POST /reddit_alerts HTTP/1.1
+ POST / HTTP/1.1
Host: ntfy.sh
- Click: https://www.reddit.com/message/messages
- New messages on Reddit
+ {
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/"
+ }
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/reddit_alerts', {
+ fetch('https://ntfy.sh', {
method: 'POST',
- body: 'New messages on Reddit',
- headers: { 'Click': 'https://www.reddit.com/message/messages' }
+ body: JSON.stringify({
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/"
+ })
})
```
=== "Go"
``` go
- req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
- req.Header.Set("Click", "https://www.reddit.com/message/messages")
+ // You should probably use json.Marshal() instead and make a proper struct,
+ // or even just use req.Header.Set() like in the other examples, but for the
+ // sake of the example, this is easier.
+
+ body := `{
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/"
+ }`
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
- $uri = "https://ntfy.sh/reddit_alerts"
- $headers = @{ Click="https://www.reddit.com/message/messages" }
- $body = "New messages on Reddit"
- Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
+ $uri = "https://ntfy.sh"
+ $body = @{
+ "topic"="powershell"
+ "title"="Low disk space alert"
+ "message"="Disk space is low at 5.1 GB"
+ "priority"=4
+ "attach"="https://filesrv.lan/space.jpg"
+ "filename"="diskspace.jpg"
+ "tags"=@("warning","cd")
+ "click"= "https://homecamera.lan/xasds1h2xsSsa/"
+ } | ConvertTo-Json
+ Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
``` python
- requests.post("https://ntfy.sh/reddit_alerts",
- data="New messages on Reddit",
- headers={ "Click": "https://www.reddit.com/message/messages" })
+ requests.post("https://ntfy.sh/",
+ data=json.dumps({
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/"
+ })
+ )
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+ file_get_contents('https://ntfy.sh/', false, stream_context_create([
'http' => [
'method' => 'POST',
- 'header' =>
- "Content-Type: text/plain\r\n" .
- "Click: https://www.reddit.com/message/messages",
- 'content' => 'New messages on Reddit'
+ 'header' => "Content-Type: application/json",
+ 'content' => json_encode([
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/"
+ ])
]
]));
```
-## User actions
+| Field | Required | Type | Example | Description |
+|----------|----------|----------------------------|-----------------|------------------------------------------------|
+| `action` | ✔️ | *view, broadcast, or http* | `view` | Action type |
+| `label` | ✔️ | *string* | `Turn on light` | Label of the action button in the notification |
+
+
+
+### Open website/app
+The `view` action opens a website or app when the action button is tapped, e.g. a browser, a Google Maps location, or
+even a deep link into Twitter or a show ntfy topic.
+
+### Send Android broadcast
+The `broadcast` action sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
+when the action button is tapped. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
+or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
+you can do everything your phone is capable of. Examples include taking pictures, launching/killing apps, change device
+settings, write/read files, etc.
+
+### Send HTTP request
+The `http` action sends a HTTP POST/GET/PUT request when the action button is tapped. You can use this to trigger REST APIs
+for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
=== "`view` action"
``` json
@@ -972,6 +1074,80 @@ Examples:
}
```
+## Click action
+You can define which URL to open when a notification is clicked. This may be useful if your notification is related
+to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
+the web browser (or the app) and open the website.
+
+Here's an example that will open Reddit when the notification is clicked:
+
+=== "Command line (curl)"
+ ```
+ curl \
+ -d "New messages on Reddit" \
+ -H "Click: https://www.reddit.com/message/messages" \
+ ntfy.sh/reddit_alerts
+ ```
+
+=== "ntfy CLI"
+ ```
+ ntfy publish \
+ --click="https://www.reddit.com/message/messages" \
+ reddit_alerts "New messages on Reddit"
+ ```
+
+=== "HTTP"
+ ``` http
+ POST /reddit_alerts HTTP/1.1
+ Host: ntfy.sh
+ Click: https://www.reddit.com/message/messages
+
+ New messages on Reddit
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.sh/reddit_alerts', {
+ method: 'POST',
+ body: 'New messages on Reddit',
+ headers: { 'Click': 'https://www.reddit.com/message/messages' }
+ })
+ ```
+
+=== "Go"
+ ``` go
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
+ req.Header.Set("Click", "https://www.reddit.com/message/messages")
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ $uri = "https://ntfy.sh/reddit_alerts"
+ $headers = @{ Click="https://www.reddit.com/message/messages" }
+ $body = "New messages on Reddit"
+ Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
+ ```
+
+=== "Python"
+ ``` python
+ requests.post("https://ntfy.sh/reddit_alerts",
+ data="New messages on Reddit",
+ headers={ "Click": "https://www.reddit.com/message/messages" })
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' =>
+ "Content-Type: text/plain\r\n" .
+ "Click: https://www.reddit.com/message/messages",
+ 'content' => 'New messages on Reddit'
+ ]
+ ]));
+ ```
## Attachments
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
@@ -1575,6 +1751,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
+| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#user-actions) |
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
diff --git a/server/errors.go b/server/errors.go
index 79dad5de..a65b4115 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -39,7 +39,7 @@ var (
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
- errHTTPBadRequestActionJSONInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions are invalid JSON", ""} // FIXME link
+ errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions are invalid format", "https://ntfy.sh/docs/publish/#user-actions"}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
diff --git a/server/server.go b/server/server.go
index 06f0f0d0..36a5f330 100644
--- a/server/server.go
+++ b/server/server.go
@@ -540,7 +540,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
if actionsStr != "" {
m.Actions, err = parseActions(actionsStr)
if err != nil {
- return false, false, "", false, errHTTPBadRequestActionJSONInvalid
+ return false, false, "", false, errHTTPBadRequestActionsInvalid
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
diff --git a/server/types.go b/server/types.go
index af651511..bea226f3 100644
--- a/server/types.go
+++ b/server/types.go
@@ -34,8 +34,6 @@ type message struct {
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
}
-// FIXME persist actions
-
type attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
diff --git a/server/util.go b/server/util.go
index 42863f74..4b6ca039 100644
--- a/server/util.go
+++ b/server/util.go
@@ -46,6 +46,7 @@ func readQueryParam(r *http.Request, names ...string) string {
}
func parseActions(s string) (actions []*action, err error) {
+ // Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
actions, err = parseActionsFromJSON(s)
@@ -55,14 +56,23 @@ func parseActions(s string) (actions []*action, err error) {
if err != nil {
return nil, err
}
+
+ // Add ID field
for i := range actions {
actions[i].ID = util.RandomString(actionIDLength)
- if !util.InStringList([]string{"view", "broadcast", "http"}, actions[i].Action) {
- return nil, fmt.Errorf("cannot parse actions: action '%s' unknown", actions[i].Action)
- } else if actions[i].Label == "" {
+ }
+
+ // Validate
+ for _, action := range actions {
+ if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
+ return nil, fmt.Errorf("cannot parse actions: action '%s' unknown", action.Action)
+ } else if action.Label == "" {
return nil, fmt.Errorf("cannot parse actions: label must be set")
+ } else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL != "" {
+ return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
}
}
+
return actions, nil
}
diff --git a/util/util_test.go b/util/util_test.go
index 45ff3de6..a3cf4a6c 100644
--- a/util/util_test.go
+++ b/util/util_test.go
@@ -152,3 +152,17 @@ func TestParseSize_FailureInvalid(t *testing.T) {
t.Fatalf("expected error, but got none")
}
}
+
+func TestSplitKV(t *testing.T) {
+ key, value := SplitKV(" key = value ", "=")
+ require.Equal(t, "key", key)
+ require.Equal(t, "value", value)
+
+ key, value = SplitKV(" value ", "=")
+ require.Equal(t, "", key)
+ require.Equal(t, "value", value)
+
+ key, value = SplitKV("mykey=value=with=separator ", "=")
+ require.Equal(t, "mykey", key)
+ require.Equal(t, "value=with=separator", value)
+}