1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-06-10 18:15:39 +00:00

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

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>

* 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

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/slack.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* docs: remove outdated comment about failing logic

* Update plugins/modules/slack.py

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>

* Update plugins/modules/slack.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: handle missing files via fail_on_file_error

* Apply suggestions from code review

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>

* fix: adjust options syntax and formatting

---------

Co-authored-by: Максим Бакуревич <maksimbakurevic@MacBook-Air-Maksim.local>
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Maxim Bakurevych 2026-05-30 14:37:04 +03:00 committed by GitHub
parent 8faf8c3838
commit 580e8ad3f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 246 additions and 33 deletions

View file

@ -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).

View file

@ -1,5 +1,6 @@
#!/usr/bin/python
# Copyright (c) 2026, Maksym Bakurevych <maxim.bakurevy@gmail.com>
# Copyright (c) 2020, Lee Goolsbee <lgoolsbee@atlassian.com>
# Copyright (c) 2020, Michal Middleton <mm.404@icloud.com>
# Copyright (c) 2017, Steve Pletcher <steve@steve-pletcher.com>
@ -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__":

View file

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