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",
)