This is an automated email from the ASF dual-hosted git repository.

choo121600 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new faf4c6feced Add thread_ts parameter to Slack operators for thread 
replies (#62289)
faf4c6feced is described below

commit faf4c6feced4fa6b2a2dbc8d4c2b2bbffb6aebf2
Author: yuseok89 <[email protected]>
AuthorDate: Fri Mar 6 00:23:37 2026 +0900

    Add thread_ts parameter to Slack operators for thread replies (#62289)
    
    * feat(slack): add thread_ts parameter for thread replies
    
    * ci: trigger CI re-run
---
 .../src/airflow/providers/slack/hooks/slack.py     | 12 ++++-
 .../src/airflow/providers/slack/operators/slack.py | 14 +++++-
 .../slack/tests/unit/slack/hooks/test_slack.py     | 19 ++++++++
 .../slack/tests/unit/slack/operators/test_slack.py | 54 ++++++++++++++++++++++
 4 files changed, 97 insertions(+), 2 deletions(-)

diff --git a/providers/slack/src/airflow/providers/slack/hooks/slack.py 
b/providers/slack/src/airflow/providers/slack/hooks/slack.py
index 1eba6174c8a..ac6d83c518f 100644
--- a/providers/slack/src/airflow/providers/slack/hooks/slack.py
+++ b/providers/slack/src/airflow/providers/slack/hooks/slack.py
@@ -227,6 +227,7 @@ class SlackHook(BaseHook):
         channel_id: str | None = None,
         file_uploads: FileUploadTypeDef | list[FileUploadTypeDef],
         initial_comment: str | None = None,
+        thread_ts: str | None = None,
     ) -> SlackResponse:
         """
         Send one or more files to a Slack channel using the Slack SDK Client 
method `files_upload_v2`.
@@ -235,6 +236,8 @@ class SlackHook(BaseHook):
             If omitting this parameter, then file will send to workspace.
         :param file_uploads: The file(s) specification to upload.
         :param initial_comment: The message text introducing the file in 
specified ``channel``.
+        :param thread_ts: Provide another message's ``ts`` value to upload the 
file as a reply in a
+            thread. See https://api.slack.com/messaging#threading.
         """
         if channel_id and channel_id.startswith("#"):
             retried_channel_id = self.get_channel_id(channel_id[1:])
@@ -260,6 +263,7 @@ class SlackHook(BaseHook):
             # see: https://github.com/python/mypy/issues/4976
             file_uploads=file_uploads,  # type: ignore[arg-type]
             initial_comment=initial_comment,
+            thread_ts=thread_ts,
         )
 
     def send_file_v1_to_v2(
@@ -272,6 +276,7 @@ class SlackHook(BaseHook):
         initial_comment: str | None = None,
         title: str | None = None,
         snippet_type: str | None = None,
+        thread_ts: str | None = None,
     ) -> list[SlackResponse]:
         """
         Smooth transition between ``send_file`` and ``send_file_v2`` methods.
@@ -285,6 +290,8 @@ class SlackHook(BaseHook):
         :param initial_comment: The message text introducing the file in 
specified ``channels``.
         :param title: Title of the file.
         :param snippet_type: Syntax type for the content being uploaded.
+        :param thread_ts: Provide another message's ``ts`` value to upload the 
file as a reply in a
+            thread. See https://api.slack.com/messaging#threading.
         """
         if not exactly_one(file, content):
             raise ValueError("Either `file` or `content` must be provided, not 
both.")
@@ -307,7 +314,10 @@ class SlackHook(BaseHook):
         for channel in channels_to_share:
             responses.append(
                 self.send_file_v2(
-                    channel_id=channel, file_uploads=file_uploads, 
initial_comment=initial_comment
+                    channel_id=channel,
+                    file_uploads=file_uploads,
+                    initial_comment=initial_comment,
+                    thread_ts=thread_ts,
                 )
             )
         return responses
diff --git a/providers/slack/src/airflow/providers/slack/operators/slack.py 
b/providers/slack/src/airflow/providers/slack/operators/slack.py
index 20f6ef7516c..bcfa8c85c99 100644
--- a/providers/slack/src/airflow/providers/slack/operators/slack.py
+++ b/providers/slack/src/airflow/providers/slack/operators/slack.py
@@ -126,9 +126,11 @@ class SlackAPIPostOperator(SlackAPIOperator):
         See https://api.slack.com/reference/block-kit/blocks
     :param attachments: (legacy) A list of attachments to send with the 
message. (templated)
         See https://api.slack.com/docs/attachments
+    :param thread_ts: Provide another message's ``ts`` value to make this 
message a reply in a
+        thread. See https://api.slack.com/messaging#threading (templated)
     """
 
-    template_fields: Sequence[str] = ("username", "text", "attachments", 
"blocks", "channel")
+    template_fields: Sequence[str] = ("username", "text", "attachments", 
"blocks", "channel", "thread_ts")
     ui_color = "#FFBA40"
 
     def __init__(
@@ -145,6 +147,7 @@ class SlackAPIPostOperator(SlackAPIOperator):
         ),
         blocks: list | None = None,
         attachments: list | None = None,
+        thread_ts: str | None = None,
         **kwargs,
     ) -> None:
         super().__init__(method="chat.postMessage", **kwargs)
@@ -154,6 +157,7 @@ class SlackAPIPostOperator(SlackAPIOperator):
         self.icon_url = icon_url
         self.attachments = attachments or []
         self.blocks = blocks or []
+        self.thread_ts = thread_ts
 
     def construct_api_call_params(self) -> Any:
         self.api_params = {
@@ -164,6 +168,8 @@ class SlackAPIPostOperator(SlackAPIOperator):
             "attachments": json.dumps(self.attachments),
             "blocks": json.dumps(self.blocks),
         }
+        if self.thread_ts is not None:
+            self.api_params["thread_ts"] = self.thread_ts
 
 
 class SlackAPIFileOperator(SlackAPIOperator):
@@ -215,6 +221,8 @@ class SlackAPIFileOperator(SlackAPIOperator):
         derived from ``filename``. (templated)
     :param snippet_type: Syntax type for the snippet being uploaded.(templated)
     :param method_version: The version of the method of Slack SDK Client to be 
used, either "v1" or "v2".
+    :param thread_ts: Provide another message's ``ts`` value to upload the 
file as a reply in a
+        thread. See https://api.slack.com/messaging#threading (templated)
     """
 
     template_fields: Sequence[str] = (
@@ -226,6 +234,7 @@ class SlackAPIFileOperator(SlackAPIOperator):
         "title",
         "display_filename",
         "snippet_type",
+        "thread_ts",
     )
     ui_color = "#44BEDF"
 
@@ -240,6 +249,7 @@ class SlackAPIFileOperator(SlackAPIOperator):
         display_filename: str | None = None,
         method_version: Literal["v1", "v2"] | None = None,
         snippet_type: str | None = None,
+        thread_ts: str | None = None,
         **kwargs,
     ) -> None:
         super().__init__(method="files.upload", **kwargs)
@@ -252,6 +262,7 @@ class SlackAPIFileOperator(SlackAPIOperator):
         self.display_filename = display_filename
         self.method_version = method_version
         self.snippet_type = snippet_type
+        self.thread_ts = thread_ts
 
         if self.filetype:
             warnings.warn(
@@ -277,4 +288,5 @@ class SlackAPIFileOperator(SlackAPIOperator):
             initial_comment=self.initial_comment,
             title=self.title,
             snippet_type=self.snippet_type,
+            thread_ts=self.thread_ts,
         )
diff --git a/providers/slack/tests/unit/slack/hooks/test_slack.py 
b/providers/slack/tests/unit/slack/hooks/test_slack.py
index 2a77601a609..1de8c06a0ea 100644
--- a/providers/slack/tests/unit/slack/hooks/test_slack.py
+++ b/providers/slack/tests/unit/slack/hooks/test_slack.py
@@ -434,6 +434,21 @@ class TestSlackHook:
             channel="C00000000",
             file_uploads=[{"file": "/foo/bar/file.txt", "filename": 
"foo.txt"}],
             initial_comment=None,
+            thread_ts=None,
+        )
+
+    def test_send_file_v2_with_thread_ts(self, mocked_client):
+        """Test that thread_ts is passed to files_upload_v2 when provided."""
+        SlackHook(slack_conn_id=SLACK_API_DEFAULT_CONN_ID).send_file_v2(
+            channel_id="C00000000",
+            file_uploads={"file": "/foo/bar/file.txt", "filename": "foo.txt"},
+            thread_ts="1234567890.123456",
+        )
+        mocked_client.files_upload_v2.assert_called_once_with(
+            channel="C00000000",
+            file_uploads=[{"file": "/foo/bar/file.txt", "filename": 
"foo.txt"}],
+            initial_comment=None,
+            thread_ts="1234567890.123456",
         )
 
     def test_send_file_v2_multiple_files(self, mocked_client):
@@ -451,6 +466,7 @@ class TestSlackHook:
                 {"content": "Some Text", "filename": "foo.txt"},
             ],
             initial_comment="Awesome File",
+            thread_ts=None,
         )
 
     def test_send_file_v2_channel_name(self, mocked_client, caplog):
@@ -465,6 +481,7 @@ class TestSlackHook:
                 channel="C00",
                 file_uploads=mock.ANY,
                 initial_comment=mock.ANY,
+                thread_ts=None,
             )
 
     @pytest.mark.parametrize("initial_comment", [None, "test comment"])
@@ -492,6 +509,7 @@ class TestSlackHook:
                     "snippet_type": snippet_type,
                 },
                 initial_comment=initial_comment,
+                thread_ts=None,
             )
 
     @pytest.mark.parametrize("initial_comment", [None, "test comment"])
@@ -519,6 +537,7 @@ class TestSlackHook:
                     "snippet_type": snippet_type,
                 },
                 initial_comment=initial_comment,
+                thread_ts=None,
             )
 
     @pytest.mark.parametrize(
diff --git a/providers/slack/tests/unit/slack/operators/test_slack.py 
b/providers/slack/tests/unit/slack/operators/test_slack.py
index dc4d5cde672..485f52a8d63 100644
--- a/providers/slack/tests/unit/slack/operators/test_slack.py
+++ b/providers/slack/tests/unit/slack/operators/test_slack.py
@@ -174,6 +174,32 @@ class TestSlackAPIPostOperator:
         }
         assert expected_api_params == slack_api_post_operator.api_params
 
+    @mock.patch("airflow.providers.slack.operators.slack.SlackHook")
+    def test_api_call_params_with_thread_ts(self, mock_hook):
+        """Test that thread_ts is passed to hook.call when provided."""
+        op = SlackAPIPostOperator(
+            task_id="slack",
+            username=self.test_username,
+            slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+            channel=self.test_channel,
+            text=self.test_text,
+            icon_url=self.test_icon_url,
+            thread_ts="1234567890.123456",
+        )
+        op.execute({})
+        mock_hook.return_value.call.assert_called_once_with(
+            "chat.postMessage",
+            json={
+                "channel": self.test_channel,
+                "username": self.test_username,
+                "text": self.test_text,
+                "icon_url": self.test_icon_url,
+                "attachments": "[]",
+                "blocks": "[]",
+                "thread_ts": "1234567890.123456",
+            },
+        )
+
 
 class TestSlackAPIFileOperator:
     def setup_method(self):
@@ -238,6 +264,7 @@ class TestSlackAPIFileOperator:
                 initial_comment=initial_comment,
                 title=title,
                 snippet_type=snippet_type,
+                thread_ts=None,
             )
 
     @pytest.mark.parametrize("initial_comment", [None, "foo-bar"])
@@ -265,6 +292,7 @@ class TestSlackAPIFileOperator:
                 initial_comment=initial_comment,
                 title=title,
                 snippet_type=snippet_type,
+                thread_ts=None,
             )
 
     def test_api_call_params_with_content_and_display_filename(self):
@@ -289,6 +317,7 @@ class TestSlackAPIFileOperator:
                 initial_comment="test",
                 title=None,
                 snippet_type=None,
+                thread_ts=None,
             )
 
     def test_api_call_params_with_file_and_display_filename(self):
@@ -313,4 +342,29 @@ class TestSlackAPIFileOperator:
                 initial_comment="test",
                 title=None,
                 snippet_type=None,
+                thread_ts=None,
+            )
+
+    def test_api_call_params_with_thread_ts(self):
+        """Test that thread_ts is passed to send_file_v1_to_v2 when 
provided."""
+        op = SlackAPIFileOperator(
+            task_id="slack",
+            slack_conn_id=SLACK_API_TEST_CONNECTION_ID,
+            channels="#test-channel",
+            content="test-content",
+            thread_ts="1234567890.123456",
+        )
+        with mock.patch(
+            
"airflow.providers.slack.operators.slack.SlackHook.send_file_v1_to_v2"
+        ) as mock_send_file:
+            op.execute({})
+            mock_send_file.assert_called_once_with(
+                channels="#test-channel",
+                content="test-content",
+                file=None,
+                filename=None,
+                initial_comment=None,
+                title=None,
+                snippet_type=None,
+                thread_ts="1234567890.123456",
             )

Reply via email to