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:
parent
8faf8c3838
commit
580e8ad3f9
3 changed files with 246 additions and 33 deletions
2
changelogs/fragments/12032-slack-files-support.yml
Normal file
2
changelogs/fragments/12032-slack-files-support.yml
Normal 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).
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue