From bced112f9ab05516b8ba2cc1c6da5c0f2a10e70b Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 15:11:47 +0200 Subject: [PATCH] [PR #12032/580e8ad3 backport][stable-13] slack: support file upload (#12126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slack: support file upload (#12032) * slack: add support for file uploads and threading * slack: add support for file uploads and threading * docs: rename fragment to match PR #12032 * Fix validate-modules issues and update documentation for files support * Fix tests * Fix tests * Fix tests * Fix tests * chore: fix nox sanity issues * style: add author copyright * style: fix examples * build: trigger CI due to infrastructure timeout * Update plugins/modules/slack.py * doc: address reviewer feedback on changelog and token placeholder * doc: address reviewer feedback on changelog and token placeholder * fix: address maintainer feedback * fix: pipeline status, rm continue * fix: fix unit tests * fix: linter fix * fix: fix comments * Update plugins/modules/slack.py * Update plugins/modules/slack.py * docs: remove outdated comment about failing logic * Update plugins/modules/slack.py * Update plugins/modules/slack.py * fix: handle missing files via fail_on_file_error * Apply suggestions from code review * Apply suggestions from code review * fix: adjust options syntax and formatting --------- (cherry picked from commit 580e8ad3f98f208525fbb265b3a460406eec2bae) Co-authored-by: Maxim Bakurevych <43715761+BakurD@users.noreply.github.com> Co-authored-by: Максим Бакуревич Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein --- .../fragments/12032-slack-files-support.yml | 2 + plugins/modules/slack.py | 235 ++++++++++++++++-- tests/unit/plugins/modules/test_slack.py | 42 +++- 3 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 changelogs/fragments/12032-slack-files-support.yml diff --git a/changelogs/fragments/12032-slack-files-support.yml b/changelogs/fragments/12032-slack-files-support.yml new file mode 100644 index 0000000000..68e7c5750e --- /dev/null +++ b/changelogs/fragments/12032-slack-files-support.yml @@ -0,0 +1,2 @@ +minor_changes: + - slack - added support for uploading files to channels and threads using the new Slack WebAPI (https://github.com/ansible-collections/community.general/pull/12032). diff --git a/plugins/modules/slack.py b/plugins/modules/slack.py index 5fc811e6d0..786f7e6254 100644 --- a/plugins/modules/slack.py +++ b/plugins/modules/slack.py @@ -1,5 +1,6 @@ #!/usr/bin/python +# Copyright (c) 2026, Maksym Bakurevych # Copyright (c) 2020, Lee Goolsbee # Copyright (c) 2020, Michal Middleton # Copyright (c) 2017, Steve Pletcher @@ -48,7 +49,7 @@ options: request access. It is there that the incoming webhooks can be added. The key is on the end of the URL given to you in that section.' - "WebAPI token: Slack WebAPI requires a personal, bot or work application token. These tokens start with V(xoxp-), - V(xoxb-) or V(xoxa-), for example V(xoxb-1234-56789abcdefghijklmnop). WebAPI token is required if you intend to receive + V(xoxb-) or V(xoxa-), for example V(xoxb-1234-56789abcdefghijklmnopqrstuvwxyz). WebAPI token is required if you intend to receive thread_id. See Slack's documentation (U(https://api.slack.com/docs/token-types)) for more information." required: true msg: @@ -149,6 +150,39 @@ options: - 'never' - 'auto' version_added: 6.1.0 + files: + type: list + elements: dict + description: + - A list of files to be uploaded to Slack. + - > + Each list item should be a dictionary containing O(files[].path) + (absolute or relative path to the file) and optionally + O(files[].name) (the filename as it will appear in Slack). + - If O(msg), O(attachments), or O(blocks) are provided, the files are attached as a reply to that message (creating a thread). + - If no message content is provided, the files are uploaded as a standalone post in the specified O(channel). + - "Note: File uploading requires a WebAPI token (starting with V(xoxb-) or V(xoxp-))." + - "It does not work with standard Incoming Webhook URLs (the ones with tokens like V(T.../B.../...) )." + - The app must have C(files:write) and C(chat:write) scopes in your Slack App settings and must be invited to the channel. + suboptions: + path: + type: path + required: true + description: + - The local path to the file to be uploaded. + name: + type: str + description: + - The name of the file as it should appear in Slack. + - If not provided, the base name of the С(path) will be used. + version_added: 13.1.0 + fail_on_file_error: + type: bool + description: + - If V(true), the module fails if a file is missing or encounters an upload error. + - If V(false), the module issues a warning and continue processing the next file. + default: true + version_added: 13.1.0 """ EXAMPLES = r""" @@ -236,13 +270,13 @@ EXAMPLES = r""" - name: Initial Threaded Slack message community.general.slack: channel: '#ansible' - token: xoxb-1234-56789abcdefghijklmnop + token: xoxb-1234-56789abcdefghijklmnopqrstuvwxyz msg: 'Starting a thread with my initial post.' register: slack_response - name: Add more info to thread community.general.slack: channel: '#ansible' - token: xoxb-1234-56789abcdefghijklmnop + token: xoxb-1234-56789abcdefghijklmnopqrstuvwxyz thread_id: "{{ slack_response['ts'] }}" color: good msg: 'And this is my threaded response!' @@ -261,8 +295,33 @@ EXAMPLES = r""" channel: "{{ slack_response.channel }}" msg: Deployment complete! message_id: "{{ slack_response.ts }}" +- name: Send file to Slack + community.general.slack: + token: "xoxb-1234-56789abcdefghijklmnopqrstuvwxyz" + channel: "channel-id" + fail_on_file_error: false # Optional, defaults to true + # If you want to sent message to channel without threads, + # you dont need to use msg parameter + msg: "Here is the file you asked for" + files: + - path: "./first.py" # file in your os + # File name in Slack. If not provided, it will be the same as path, + # so in this case "first.py": + name: "test_report.py" + - path: "./test_file.txt" + name: "test_report.txt" +- name: Send file to Slack threads + community.general.slack: + token: "xoxb-1234-56789abcdefghijklmnopqrstuvwxyz" + channel: "channel-id" + thread_id: "thread-id" # if you want to send file to a specific thread + files: + - path: "./first.py" # file in your os + # File name in Slack. If not provided, it will be the same as path, + # so in this case "first.py": + name: "test_report.py" """ - +import os import re from urllib.parse import urlencode @@ -452,6 +511,92 @@ def do_notify_slack(module, domain, token, payload): return {"webhook": "ok"} +def upload_slack_files(module, token, channel, files, thread_ts=None, fail_on_file_error=True): + if not files: + return {"ok": False, "msg": "No files provided"} + + uploaded_ids = [] + headers = {"Authorization": f"Bearer {token}"} + + for f_item in files: + f_path = f_item["path"] + f_name = f_item["name"] or os.path.basename(f_path) + + if not os.path.exists(f_path): + error_msg = f"File {f_path} not found." + if fail_on_file_error: + module.fail_json(msg=error_msg) + else: + module.warn(f"{error_msg} Skipping.") + continue + + file_size = os.path.getsize(f_path) + url_get = f"https://slack.com/api/files.getUploadURLExternal?filename={f_name}&length={file_size}" + + resp, info = fetch_url(module, url_get, headers=headers, method="GET") + + if info["status"] != 200: + module.fail_json( + msg=f"Failed to get upload URL for {f_name}. Slack API endpoint returned HTTP {info['status']}.", + details=info.get("msg", "No HTTP error message provided"), + ) + + res = module.from_json(resp.read()) + + if not res.get("ok"): + error_code = res.get("error", "unknown_error") + fatal_errors = ["invalid_auth", "unknown_method", "missing_scope", "account_inactive"] + if error_code in fatal_errors: + module.fail_json( + msg=f"Fatal Slack API error occurred for {f_name}. Operation aborted.", error=error_code + ) + module.warn(f"Slack API error for {f_name}: {error_code}") + continue + + try: + with open(f_path, "rb") as f: + file_data = f.read() + + u_resp, u_info = fetch_url( + module, + res["upload_url"], + data=file_data, + method="POST", + headers={"Content-Type": "application/octet-stream"}, + ) + + if u_info["status"] != 200: + module.warn(f"Failed to upload bits for {f_name}. Status: {u_info['status']}") + continue + + except Exception as e: + module.warn(f"Failed to upload bits for {f_name}: {e}") + continue + + uploaded_ids.append({"id": res["file_id"], "title": f_name}) + + if uploaded_ids: + completion_payload = {"files": uploaded_ids, "channel_id": channel, "initial_comment": "Attached Files:"} + + if thread_ts: + completion_payload["thread_ts"] = thread_ts + + f_url = "https://slack.com/api/files.completeUploadExternal" + final_headers = headers.copy() + final_headers["Content-Type"] = "application/json; charset=utf-8" + + resp, info = fetch_url( + module, f_url, headers=final_headers, method="POST", data=module.jsonify(completion_payload) + ) + + if info["status"] != 200: + return {"ok": False, "msg": f"Failed to complete upload. Status: {info['status']}"} + + return module.from_json(resp.read()) + + return {"ok": False, "msg": "No files were successfully uploaded"} + + def main(): module = AnsibleModule( argument_spec=dict( @@ -471,6 +616,15 @@ def main(): blocks=dict(type="list", elements="dict"), message_id=dict(type="str"), prepend_hash=dict(type="str", choices=["always", "never", "auto"], default="never"), + fail_on_file_error=dict(type="bool", default=True), + files=dict( + type="list", + elements="dict", + options=dict( + path=dict(type="path", required=True), + name=dict(type="str"), + ), + ), ), supports_check_mode=True, ) @@ -490,7 +644,16 @@ def main(): blocks = module.params["blocks"] message_id = module.params["message_id"] prepend_hash = module.params["prepend_hash"] - + fail_on_file_error = module.params["fail_on_file_error"] + files = module.params["files"] + is_webhook = re.match(r"^T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+$", token) + is_api_token = re.match(r"^xox[bpa]-", token) + if not (is_webhook or is_api_token): + module.fail_json( + msg="The token provided is not a valid Slack token. " + "Webhooks should look like T.../B.../X... and " + "API tokens should start with xoxb-, xoxp-, or xoxa-." + ) color_choices = ["normal", "good", "warning", "danger"] if color not in color_choices and not is_valid_hex_color(color): module.fail_json( @@ -529,26 +692,56 @@ def main(): message_id, prepend_hash, ) - slack_response = do_notify_slack(module, domain, token, payload) - if "ok" in slack_response: - # Evaluate WebAPI response - if slack_response["ok"]: - # return payload as a string for backwards compatibility - payload_json = module.jsonify(payload) - module.exit_json( - changed=changed, - ts=slack_response["ts"], - channel=slack_response["channel"], - api=slack_response, - payload=payload_json, - ) - else: - module.fail_json(msg="Slack API error", error=slack_response["error"]) + has_message_content = bool(text or attachments or blocks) + slack_response = {} + is_success = False + + if has_message_content: + slack_response = do_notify_slack(module, domain, token, payload) + # Check success for both WebAPI (ok: true) and incoming webhooks + # (webhook: ok) + is_success = slack_response.get("ok") or slack_response.get("webhook") == "ok" else: + is_success = True + + file_upload_res = None + if files and is_success: + target_channel = slack_response.get("channel") or channel + target_thread = slack_response.get("ts") or thread_id + + file_upload_res = upload_slack_files( + module, token, target_channel, files, thread_ts=target_thread, fail_on_file_error=fail_on_file_error + ) + + # If sending only files, overall success depends on the upload result + if not has_message_content: + is_success = file_upload_res.get("ok", False) + + if is_success: # Exit with plain OK from WebHook, since we don't have more information # If we get 200 from webhook, the only answer is OK - module.exit_json(msg="OK") + if "ok" not in slack_response and slack_response.get("webhook") == "ok" and not files: + module.exit_json(msg="OK", changed=True) + + result = { + "changed": True, + "api": slack_response if has_message_content else {"status": "files_only_upload"}, + "payload": module.jsonify(payload) if has_message_content else None, + } + + if file_upload_res: + result["files_upload"] = file_upload_res + + if "ts" in slack_response: + result.update({"ts": slack_response["ts"], "channel": slack_response["channel"]}) + elif file_upload_res and "files" in file_upload_res: + result.update({"channel": channel}) + + module.exit_json(**result) + else: + error_msg = slack_response.get("error") or (file_upload_res.get("msg") if file_upload_res else "Unknown error") + module.fail_json(msg="Slack operation failed", error=error_msg) if __name__ == "__main__": diff --git a/tests/unit/plugins/modules/test_slack.py b/tests/unit/plugins/modules/test_slack.py index 89ff19d7fc..20020e7e87 100644 --- a/tests/unit/plugins/modules/test_slack.py +++ b/tests/unit/plugins/modules/test_slack.py @@ -5,7 +5,7 @@ from __future__ import annotations import json -from unittest.mock import Mock, patch +from unittest.mock import Mock, mock_open, patch import pytest from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( @@ -48,7 +48,7 @@ class TestSlackModule(ModuleTestCase): def test_successful_message(self): """tests sending a message. This is example 1 from the docs""" - with set_module_args({"token": "XXXX/YYYY/ZZZZ", "msg": "test"}): + with set_module_args({"token": "TXX/BYY/ZZZ", "msg": "test"}): with patch.object(slack, "fetch_url") as fetch_url_mock: fetch_url_mock.return_value = (None, {"status": 200}) with self.assertRaises(AnsibleExitJson): @@ -58,12 +58,12 @@ class TestSlackModule(ModuleTestCase): call_data = json.loads(fetch_url_mock.call_args[1]["data"]) assert call_data["username"] == "Ansible" assert call_data["text"] == "test" - assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ" + assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/TXX/BYY/ZZZ" def test_failed_message(self): """tests failing to send a message""" - with set_module_args({"token": "XXXX/YYYY/ZZZZ", "msg": "test"}): + with set_module_args({"token": "TXX/BYY/ZZZ", "msg": "test"}): with patch.object(slack, "fetch_url") as fetch_url_mock: fetch_url_mock.return_value = (None, {"status": 404, "msg": "test"}) with self.assertRaises(AnsibleFailJson): @@ -71,7 +71,7 @@ class TestSlackModule(ModuleTestCase): def test_message_with_thread(self): """tests sending a message with a thread""" - with set_module_args({"token": "XXXX/YYYY/ZZZZ", "msg": "test", "thread_id": "100.00"}): + with set_module_args({"token": "TXX/BYY/ZZZ", "msg": "test", "thread_id": "100.00"}): with patch.object(slack, "fetch_url") as fetch_url_mock: fetch_url_mock.return_value = (None, {"status": 200}) with self.assertRaises(AnsibleExitJson): @@ -82,14 +82,14 @@ class TestSlackModule(ModuleTestCase): assert call_data["username"] == "Ansible" assert call_data["text"] == "test" assert call_data["thread_ts"] == "100.00" - assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ" + assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/TXX/BYY/ZZZ" # https://github.com/ansible-collections/community.general/issues/1097 def test_ts_in_message_does_not_cause_edit(self): with set_module_args({"token": "xoxa-123456789abcdef", "msg": "test with ts"}): with patch.object(slack, "fetch_url") as fetch_url_mock: mock_response = Mock() - mock_response.read.return_value = '{"fake":"data"}' + mock_response.read.return_value = '{"ok": true, "fake":"data"}' fetch_url_mock.return_value = (mock_response, {"status": 200}) with self.assertRaises(AnsibleExitJson): self.module.main() @@ -101,7 +101,7 @@ class TestSlackModule(ModuleTestCase): with set_module_args({"token": "xoxa-123456789abcdef", "domain": "slack-gov.com", "msg": "test with ts"}): with patch.object(slack, "fetch_url") as fetch_url_mock: mock_response = Mock() - mock_response.read.return_value = '{"fake":"data"}' + mock_response.read.return_value = '{"ok": true, "fake":"data"}' fetch_url_mock.return_value = (mock_response, {"status": 200}) with self.assertRaises(AnsibleExitJson): self.module.main() @@ -113,7 +113,7 @@ class TestSlackModule(ModuleTestCase): with set_module_args({"token": "xoxa-123456789abcdef", "msg": "test2", "message_id": "12345"}): with patch.object(slack, "fetch_url") as fetch_url_mock: mock_response = Mock() - mock_response.read.return_value = '{"messages":[{"ts":"12345","msg":"test1"}]}' + mock_response.read.return_value = '{"ok": true, "messages":[{"ts":"12345","msg":"test1"}]}' fetch_url_mock.side_effect = [ (mock_response, {"status": 200}), (mock_response, {"status": 200}), @@ -130,7 +130,7 @@ class TestSlackModule(ModuleTestCase): """tests sending a message with blocks""" with set_module_args( { - "token": "XXXX/YYYY/ZZZZ", + "token": "TXX/BYY/ZZZ", "msg": "test", "blocks": [ { @@ -155,13 +155,13 @@ class TestSlackModule(ModuleTestCase): call_data = json.loads(fetch_url_mock.call_args[1]["data"]) assert call_data["username"] == "Ansible" assert call_data["blocks"][1]["text"]["text"] == "test" - assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ" + assert fetch_url_mock.call_args[1]["url"] == "https://hooks.slack.com/services/TXX/BYY/ZZZ" def test_message_with_invalid_color(self): """tests sending invalid color value to module""" with set_module_args( { - "token": "XXXX/YYYY/ZZZZ", + "token": "TXX/BYY/ZZZ", "msg": "test", "color": "aa", } @@ -176,6 +176,24 @@ class TestSlackModule(ModuleTestCase): ) assert exec_info.exception.args[0]["msg"] == msg + def test_upload_files_only(self): + with set_module_args( + {"token": "xoxb-12345", "channel": "C123", "files": [{"path": "/tmp/test.txt", "name": "hello.txt"}]} + ): + with patch.object(slack, "fetch_url") as fetch_url_mock: + with patch("os.path.exists", return_value=True): + with patch("os.path.getsize", return_value=100): + with patch("builtins.open", mock_open(read_data=b"data")): + mock_resp = Mock() + mock_resp.read.side_effect = [ + '{"ok": true, "upload_url": "https://upload", "file_id": "F1"}', + '{"ok": true}', + ] + fetch_url_mock.return_value = (mock_resp, {"status": 200}) + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertTrue(result.exception.args[0]["changed"]) + color_test = [ ("#111111", True),