diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 57a1c69ac973e6..5dcf82ac3b5ef3 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -75,6 +75,9 @@ }, "services": { "publish": { + "sections": { + "actions": "mdi:gesture-tap-button" + }, "service": "mdi:send" } } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 176dddd7a44ce2..7d25e8cb569175 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -5,7 +5,7 @@ from datetime import timedelta from typing import Any -from aiontfy import Message +from aiontfy import BroadcastAction, HttpAction, Message, ViewAction from aiontfy.exceptions import ( NtfyException, NtfyHTTPError, @@ -31,7 +31,7 @@ from .entity import NtfyBaseEntity PARALLEL_UPDATES = 0 - +MAX_ACTIONS_ALLOWED = 3 # ntfy only supports up to 3 actions per notification SERVICE_PUBLISH = "publish" ATTR_ATTACH = "attach" @@ -43,7 +43,53 @@ ATTR_MARKDOWN = "markdown" ATTR_PRIORITY = "priority" ATTR_TAGS = "tags" - +ATTR_ACTIONS = "actions" +ATTR_ACTION = "action" +ATTR_VIEW = "view" +ATTR_BROADCAST = "broadcast" +ATTR_HTTP = "http" +ATTR_LABEL = "label" +ATTR_URL = "url" +ATTR_CLEAR = "clear" +ATTR_POSITION = "position" +ATTR_INTENT = "intent" +ATTR_EXTRAS = "extras" +ATTR_METHOD = "method" +ATTR_HEADERS = "headers" +ATTR_BODY = "body" +ACTIONS_MAP = { + ATTR_VIEW: ViewAction, + ATTR_BROADCAST: BroadcastAction, + ATTR_HTTP: HttpAction, +} + +ACTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_LABEL): cv.string, + vol.Optional(ATTR_CLEAR, default=False): cv.boolean, + } +) +VIEW_SCHEMA = ACTION_SCHEMA.extend( + { + vol.Optional(ATTR_ACTION): vol.All(str, "view"), + vol.Required(ATTR_URL): cv.url, + } +) +BROADCAST_SCHEMA = ACTION_SCHEMA.extend( + { + vol.Optional(ATTR_ACTION): vol.All(str, "broadcast"), + vol.Optional(ATTR_INTENT): cv.string, + vol.Optional(ATTR_EXTRAS): dict[str, str], + } +) +HTTP_SCHEMA = VIEW_SCHEMA.extend( + { + vol.Optional(ATTR_ACTION): vol.All(str, "http"), + vol.Optional(ATTR_METHOD): cv.string, + vol.Optional(ATTR_HEADERS): dict[str, str], + vol.Optional(ATTR_BODY): cv.string, + } +) SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema( { vol.Optional(ATTR_TITLE): cv.string, @@ -60,6 +106,9 @@ vol.Optional(ATTR_EMAIL): vol.Email(), vol.Optional(ATTR_CALL): cv.string, vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)), + vol.Optional(ATTR_ACTIONS): vol.All( + cv.ensure_list, [vol.Any(VIEW_SCHEMA, BROADCAST_SCHEMA, HTTP_SCHEMA)] + ), } ) @@ -116,6 +165,17 @@ async def publish(self, **kwargs: Any) -> None: translation_key="delay_no_call", ) + actions: list[dict[str, Any]] | None = params.get(ATTR_ACTIONS) + if actions: + if len(actions) > MAX_ACTIONS_ALLOWED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="too_many_actions", + ) + params["actions"] = [ + ACTIONS_MAP[action.pop(ATTR_ACTION)](**action) for action in actions + ] + msg = Message(topic=self.topic, **params) try: await self.ntfy.publish(msg) diff --git a/homeassistant/components/ntfy/services.yaml b/homeassistant/components/ntfy/services.yaml index 2c8e00746e5a27..1719221deb7b1f 100644 --- a/homeassistant/components/ntfy/services.yaml +++ b/homeassistant/components/ntfy/services.yaml @@ -88,3 +88,58 @@ publish: type: url autocomplete: url example: https://example.org/logo.png + actions: + selector: + object: + label_field: "label" + description_field: "url" + multiple: true + translation_key: actions + fields: + action: + selector: + select: + options: + - value: view + label: Open website/app + - value: broadcast + label: Send Android broadcast + - value: http + label: Send HTTP request + translation_key: action_type + mode: dropdown + label: + selector: + text: + required: true + url: + selector: + text: + type: url + required: true + intent: + selector: + text: + required: true + extras: + selector: + object: + method: + selector: + select: + options: + - GET + - POST + - PUT + - DELETE + custom_value: true + headers: + selector: + object: + body: + selector: + text: + multiline: true + clear: + selector: + boolean: diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 760f61530172fd..7f24229ca0503a 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -290,6 +290,9 @@ }, "timeout_error": { "message": "Failed to connect to ntfy service due to a connection timeout" + }, + "too_many_actions": { + "message": "Too many actions defined. A maximum of 3 is supported" } }, "issues": { @@ -306,6 +309,19 @@ } }, "selector": { + "actions": { + "fields": { + "action": "Action type", + "body": "Body of the HTTP request ('http' action only)", + "clear": "Clear notification after action button is tapped", + "extras": "Extras to include in the intent as key-value pairs ('broadcast' action only)", + "headers": "Additional HTTP headers as key-value pairs ('http' action only)", + "intent": "Android intent to send when the 'broadcast' action is triggered", + "label": "Label of the action button", + "method": "HTTP method to use for the 'http' action", + "url": "URL to open for the 'view' action or to request for the 'http' action" + } + }, "priority": { "options": { "1": "Minimum", @@ -320,6 +336,10 @@ "publish": { "description": "Publishes a notification message to a ntfy topic", "fields": { + "actions": { + "description": "Up to three actions ('view', 'broadcast', or 'http') can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.", + "name": "Action buttons" + }, "attach": { "description": "Attach images or other files by URL.", "name": "Attachment URL" diff --git a/tests/components/ntfy/test_services.py b/tests/components/ntfy/test_services.py index d07df40264f6a1..af63522c6883fe 100644 --- a/tests/components/ntfy/test_services.py +++ b/tests/components/ntfy/test_services.py @@ -2,7 +2,7 @@ from typing import Any -from aiontfy import Message +from aiontfy import BroadcastAction, HttpAction, Message, ViewAction from aiontfy.exceptions import ( NtfyException, NtfyHTTPError, @@ -14,6 +14,7 @@ from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TITLE from homeassistant.components.ntfy.const import DOMAIN from homeassistant.components.ntfy.notify import ( + ATTR_ACTIONS, ATTR_ATTACH, ATTR_CALL, ATTR_CLICK, @@ -60,6 +61,29 @@ async def test_ntfy_publish( ATTR_MARKDOWN: True, ATTR_PRIORITY: "5", ATTR_TAGS: ["partying_face", "grin"], + ATTR_ACTIONS: [ + { + "action": "broadcast", + "label": "Take picture", + "intent": "com.example.AN_INTENT", + "extras": {"cmd": "pic"}, + "clear": True, + }, + { + "action": "view", + "label": "Open website", + "url": "https://example.com", + "clear": False, + }, + { + "action": "http", + "label": "Close door", + "url": "https://api.example.local/", + "method": "PUT", + "headers": {"Authorization": "Bearer ..."}, + "clear": False, + }, + ], }, blocking=True, ) @@ -76,6 +100,27 @@ async def test_ntfy_publish( markdown=True, icon=URL("https://example.org/logo.png"), delay="86430.0s", + actions=[ + BroadcastAction( + label="Take picture", + intent="com.example.AN_INTENT", + extras={"cmd": "pic"}, + clear=True, + ), + ViewAction( + label="Open website", + url="https://example.com", + clear=False, + ), + HttpAction( + label="Close door", + url="https://api.example.local/", + method="PUT", + headers={"Authorization": "Bearer ..."}, + body=None, + clear=False, + ), + ], ) ) @@ -142,12 +187,23 @@ async def test_send_message_exception( {ATTR_DELAY: {"days": 1, "seconds": 30}, ATTR_EMAIL: "mail@example.org"}, "Delayed email notifications are not supported", ), + ( + { + ATTR_ACTIONS: [ + {"action": "broadcast", "label": "1"}, + {"action": "broadcast", "label": "2"}, + {"action": "broadcast", "label": "3"}, + {"action": "broadcast", "label": "4"}, + ], + }, + "Too many actions defined. A maximum of 3 is supported", + ), ], ) +@pytest.mark.usefixtures("mock_aiontfy") async def test_send_message_validation_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_aiontfy: AsyncMock, payload: dict[str, Any], error_msg: str, ) -> None: