From 11c4c37cf9f85f8e6c5a6c97eb830b9e90dcf5da Mon Sep 17 00:00:00 2001 From: Fletcher Date: Thu, 21 Sep 2023 17:06:55 +0800 Subject: [PATCH] Add Slack thread/reply support (#93384) --- CODEOWNERS | 4 +-- homeassistant/components/slack/const.py | 1 + homeassistant/components/slack/manifest.json | 2 +- homeassistant/components/slack/notify.py | 29 ++++++++++++++++++-- tests/components/slack/test_notify.py | 16 +++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f3ff4024677..5bd97369ef5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1145,8 +1145,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @tkdrob -/tests/components/slack/ @tkdrob +/homeassistant/components/slack/ @tkdrob @fletcherau +/tests/components/slack/ @tkdrob @fletcherau /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/slack/const.py b/homeassistant/components/slack/const.py index ec0993e290b..ccc1fbb6643 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -10,6 +10,7 @@ ATTR_SNOOZE = "snooze_endtime" ATTR_URL = "url" ATTR_USERNAME = "username" ATTR_USER_ID = "user_id" +ATTR_THREAD_TS = "thread_ts" CONF_DEFAULT_CHANNEL = "default_channel" diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 2bd3476cbbe..1b35db6f061 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@tkdrob"], + "codeowners": ["@tkdrob", "@fletcherau"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 498eddffa3d..deba0796750 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -30,6 +30,7 @@ from .const import ( ATTR_FILE, ATTR_PASSWORD, ATTR_PATH, + ATTR_THREAD_TS, ATTR_URL, ATTR_USERNAME, CONF_DEFAULT_CHANNEL, @@ -50,7 +51,10 @@ FILE_URL_SCHEMA = vol.Schema( ) DATA_FILE_SCHEMA = vol.Schema( - {vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)} + { + vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA), + vol.Optional(ATTR_THREAD_TS): cv.string, + } ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( @@ -59,6 +63,7 @@ DATA_TEXT_ONLY_SCHEMA = vol.Schema( vol.Optional(ATTR_ICON): cv.string, vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list, + vol.Optional(ATTR_THREAD_TS): cv.string, } ) @@ -73,7 +78,7 @@ class AuthDictT(TypedDict, total=False): auth: BasicAuth -class FormDataT(TypedDict): +class FormDataT(TypedDict, total=False): """Type for form data, file upload.""" channels: str @@ -81,6 +86,7 @@ class FormDataT(TypedDict): initial_comment: str title: str token: str + thread_ts: str # Optional key class MessageT(TypedDict, total=False): @@ -92,6 +98,7 @@ class MessageT(TypedDict, total=False): icon_url: str # Optional key icon_emoji: str # Optional key blocks: list[Any] # Optional key + thread_ts: str # Optional key async def async_get_service( @@ -142,6 +149,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, ) -> None: """Upload a local file (with message) to Slack.""" if not self._hass.config.is_allowed_path(path): @@ -158,6 +166,7 @@ class SlackNotificationService(BaseNotificationService): filename=filename, initial_comment=message, title=title or filename, + thread_ts=thread_ts, ) except (SlackApiError, ClientError) as err: _LOGGER.error("Error while uploading file-based message: %r", err) @@ -168,6 +177,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, password: str | None = None, @@ -205,6 +215,9 @@ class SlackNotificationService(BaseNotificationService): "token": self._client.token, } + if thread_ts: + form_data["thread_ts"] = thread_ts + data = FormData(form_data, charset="utf-8") data.add_field("file", resp.content, filename=filename) @@ -218,6 +231,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, icon: str | None = None, @@ -238,6 +252,9 @@ class SlackNotificationService(BaseNotificationService): if blocks: message_dict["blocks"] = blocks + if thread_ts: + message_dict["thread_ts"] = thread_ts + tasks = { target: self._client.chat_postMessage(**message_dict, channel=target) for target in targets @@ -286,6 +303,7 @@ class SlackNotificationService(BaseNotificationService): title, username=data.get(ATTR_USERNAME, self._config.get(ATTR_USERNAME)), icon=data.get(ATTR_ICON, self._config.get(ATTR_ICON)), + thread_ts=data.get(ATTR_THREAD_TS), blocks=blocks, ) @@ -296,11 +314,16 @@ class SlackNotificationService(BaseNotificationService): targets, message, title, + thread_ts=data.get(ATTR_THREAD_TS), username=data[ATTR_FILE].get(ATTR_USERNAME), password=data[ATTR_FILE].get(ATTR_PASSWORD), ) # Message Type 3: A message that uploads a local file return await self._async_send_local_file_message( - data[ATTR_FILE][ATTR_PATH], targets, message, title + data[ATTR_FILE][ATTR_PATH], + targets, + message, + title, + thread_ts=data.get(ATTR_THREAD_TS), ) diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 232f78e97e4..6c90ad8cd39 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock from homeassistant.components import notify from homeassistant.components.slack import DOMAIN from homeassistant.components.slack.notify import ( + ATTR_THREAD_TS, CONF_DEFAULT_CHANNEL, SlackNotificationService, ) @@ -93,3 +94,18 @@ async def test_message_icon_url_overrides_default() -> None: mock_fn.assert_called_once() _, kwargs = mock_fn.call_args assert kwargs["icon_url"] == expected_icon + + +async def test_message_as_reply() -> None: + """Tests that a message pointer will be passed to Slack if specified.""" + mock_client = Mock() + mock_client.chat_postMessage = AsyncMock() + service = SlackNotificationService(None, mock_client, CONF_DATA) + + expected_ts = "1624146685.064129" + await service.async_send_message("test", data={ATTR_THREAD_TS: expected_ts}) + + mock_fn = mock_client.chat_postMessage + mock_fn.assert_called_once() + _, kwargs = mock_fn.call_args + assert kwargs["thread_ts"] == expected_ts