From d71a6e8fd4311322fb17808de83b8c7792b0811d Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 30 Dec 2024 10:14:41 +0100 Subject: [PATCH 1/2] feat(slack) : Add file upload functionality --- plugins/modules/slack.py | 220 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/plugins/modules/slack.py b/plugins/modules/slack.py index 05d34e94b98..ad5a385ecb9 100644 --- a/plugins/modules/slack.py +++ b/plugins/modules/slack.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# Copyright (c) 2024, Matthias Colin # Copyright (c) 2020, Lee Goolsbee # Copyright (c) 2020, Michal Middleton # Copyright (c) 2017, Steve Pletcher @@ -143,6 +144,33 @@ - 'never' - 'auto' version_added: 6.1.0 + upload_file: + type: dict + description: + - Specify details to upload a file to Slack. The file can include metadata such as an initial comment, alt text, snipped and title. + - See Slack's file upload API for details at U(https://api.slack.com/methods/files.getUploadURLExternal) and U(https://api.slack.com/methods/files.completeUploadExternal). + suboptions: + path: + type: str + description: + - Path to the file on the local system to upload. + required: true + initial_comment: + type: str + description: + - Optional comment to include when uploading the file. + alt_text: + type: str + description: + - Optional alternative text to describe the file. + snippet_type: + type: str + description: + - Optional snippet type for the file. + title: + type: str + description: + - Optional title for the uploaded file. """ EXAMPLES = r""" @@ -254,9 +282,22 @@ channel: "{{ slack_response.channel }}" msg: Deployment complete! message_id: "{{ slack_response.ts }}" + +- name: Upload a file to Slack + community.general.slack: + token: thetoken/generatedby/slack + channel: 'ansible' + upload_file: + path: /path/to/file.txt + initial_comment: '' + alt_text: '' + snippet_type: '' + title: '' """ import re +import json +import os from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils.urls import fetch_url @@ -266,6 +307,9 @@ SLACK_POSTMESSAGE_WEBAPI = 'https://slack.com/api/chat.postMessage' SLACK_UPDATEMESSAGE_WEBAPI = 'https://slack.com/api/chat.update' SLACK_CONVERSATIONS_HISTORY_WEBAPI = 'https://slack.com/api/conversations.history' +SLACK_GET_UPLOAD_URL_EXTERNAL = 'https://slack.com/api/files.getUploadURLExternal' +SLACK_COMPLETE_UPLOAD_EXTERNAL = 'https://slack.com/api/files.completeUploadExternal' +SLACK_CONVERSATIONS_LIST_WEBAPI = 'https://slack.com/api/conversations.list' # Escaping quotes and apostrophes to avoid ending string prematurely in ansible call. # We do not escape other characters used as Slack metacharacters (e.g. &, <, >). @@ -430,6 +474,159 @@ def do_notify_slack(module, domain, token, payload): else: return {'webhook': 'ok'} +def get_channel_id(module, token, channel_name): + url = SLACK_CONVERSATIONS_LIST_WEBAPI + headers = {"Authorization": f"Bearer {token}"} + params = { + "types": "public_channel,private_channel,mpim,im", + "limit": 1000, + "exclude_archived": "true", + } + cursor = None + while True: + if cursor: + params["cursor"] = cursor + query = urlencode(params) + full_url = f"{url}?{query}" + response, info = fetch_url(module, full_url, headers=headers, method="GET") + status = info.get("status") + if status != 200: + error_msg = info.get("msg", "Unknown error") + module.fail_json( + msg=f"Failed to retrieve channels: {error_msg} (HTTP {status})" + ) + try: + response_body = response.read().decode("utf-8") if response else "" + data = json.loads(response_body) + except json.JSONDecodeError as e: + module.fail_json(msg=f"JSON decode error: {e}") + if not data.get("ok"): + error = data.get("error", "Unknown error") + module.fail_json(msg=f"Slack API error: {error}") + channels = data.get("channels", []) + for channel in channels: + if channel.get("name") == channel_name: + channel_id = channel.get("id") + return channel_id + cursor = data.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + module.fail_json(msg=f"Channel named '{channel_name}' not found.") + + +def upload_file_to_slack(module, token, channel, file_upload): + try: + file_path = file_upload["path"] + if not os.path.exists(file_path): + module.fail_json(msg=f"File not found: {file_path}") + # Step 1: Get upload URL + url = SLACK_GET_UPLOAD_URL_EXTERNAL + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/x-www-form-urlencoded", + } + params = urlencode( + { + "filename": file_upload.get("filename", os.path.basename(file_path)), + "length": os.path.getsize(file_path), + **( + {"alt_text": file_upload.get("alt_text")} + if file_upload.get("alt_text") + else {} + ), + **( + {"snippet_type": file_upload.get("snippet_type")} + if file_upload.get("snippet_type") + else {} + ), + } + ) + response, info = fetch_url( + module, f"{url}?{params}", headers=headers, method="GET" + ) + if info["status"] != 200: + module.fail_json( + msg=f"Error retrieving upload URL: {info['msg']} (HTTP {info['status']})" + ) + try: + upload_url_data = json.load(response) + except json.JSONDecodeError: + module.fail_json( + msg=f"The Slack API response is not valid JSON: {response.read()}" + ) + if not upload_url_data.get("ok"): + module.fail_json( + msg=f"Failed to retrieve upload URL: {upload_url_data.get('error')}" + ) + upload_url = upload_url_data["upload_url"] + file_id = upload_url_data["file_id"] + # Step 2: Upload file content + try: + with open(file_path, "rb") as file: + file_content = file.read() + response, info = fetch_url( + module, + upload_url, + data=file_content, + headers={"Content-Type": "application/octet-stream"}, + method="POST", + ) + if info["status"] != 200: + module.fail_json( + msg=f"Error during file upload: {info['msg']} (HTTP {info['status']})" + ) + except FileNotFoundError: + module.fail_json(msg=f"The file {file_path} is not found.") + # Step 3: Complete upload + complete_url = SLACK_COMPLETE_UPLOAD_EXTERNAL + files_data = json.dumps( + { + "files": [ + { + "id": file_id, + **( + {"title": file_upload.get("title")} + if file_upload.get("title") + else {} + ), + } + ], + **( + {"initial_comment": file_upload.get("initial_comment")} + if file_upload.get("initial_comment") + else {} + ), + **( + {"thread_ts": file_upload.get("thread_ts")} + if file_upload.get("thread_ts") + else {} + ), + "channel_id": get_channel_id(module, token, channel), + } + ) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + response, info = fetch_url( + module, complete_url, data=files_data, headers=headers, method="POST" + ) + if info["status"] != 200: + module.fail_json( + msg=f"Error during upload completion: {info['msg']} (HTTP {info['status']})" + ) + upload_url_data = json.load(response) + except json.JSONDecodeError: + module.fail_json( + msg=f"The Slack API response is not valid JSON: {response.read()}" + ) + if not upload_url_data.get("ok"): + module.fail_json(msg=f"Failed to complete the upload: {upload_url_data}") + return upload_url_data + except Exception as e: + module.fail_json(msg=f"Error uploading file: {str(e)}") + def main(): module = AnsibleModule( @@ -450,6 +647,15 @@ def main(): blocks=dict(type='list', elements='dict'), message_id=dict(type='str'), prepend_hash=dict(type='str', choices=['always', 'never', 'auto']), + upload_file=dict(type="dict", options=dict( + path=dict(type="str", required=True), + alt_text=dict(type="str"), + snippet_type=dict(type="str"), + initial_comment=dict(type="str"), + thread_ts=dict(type="str"), + title=dict(type="str") + ) + ), ), supports_check_mode=True, ) @@ -469,6 +675,20 @@ def main(): blocks = module.params['blocks'] message_id = module.params['message_id'] prepend_hash = module.params['prepend_hash'] + upload_file = module.params["upload_file"] + + if upload_file: + try: + upload_response = upload_file_to_slack( + module=module, token=token, channel=channel, file_upload=upload_file + ) + module.exit_json( + changed=True, + msg="File uploaded successfully", + upload_response=upload_response, + ) + except Exception as e: + module.fail_json(msg=f"Failed to upload file: {str(e)}") if prepend_hash is None: module.deprecate( From c0d8ffc4446595daa39e0b5cd5709163fef5ac70 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 30 Dec 2024 10:18:42 +0100 Subject: [PATCH 2/2] Changelog fragment --- changelogs/fragments/slack.yml | 2 + plugins/modules/slack.py | 140 ++++++++++++++++----------------- 2 files changed, 68 insertions(+), 74 deletions(-) create mode 100644 changelogs/fragments/slack.yml diff --git a/changelogs/fragments/slack.yml b/changelogs/fragments/slack.yml new file mode 100644 index 00000000000..5a55ea49971 --- /dev/null +++ b/changelogs/fragments/slack.yml @@ -0,0 +1,2 @@ +minor_changes: + - Slack upload file - feature added support for uploading files to Slack (https://github.com/ansible-collections/community.general/pull/9472). diff --git a/plugins/modules/slack.py b/plugins/modules/slack.py index ad5a385ecb9..c7f75ca2312 100644 --- a/plugins/modules/slack.py +++ b/plugins/modules/slack.py @@ -148,7 +148,8 @@ type: dict description: - Specify details to upload a file to Slack. The file can include metadata such as an initial comment, alt text, snipped and title. - - See Slack's file upload API for details at U(https://api.slack.com/methods/files.getUploadURLExternal) and U(https://api.slack.com/methods/files.completeUploadExternal). + - See Slack's file upload API for details at U(https://api.slack.com/methods/files.getUploadURLExternal). + - See Slack's file upload API for details at U(https://api.slack.com/methods/files.completeUploadExternal). suboptions: path: type: str @@ -171,6 +172,10 @@ type: str description: - Optional title for the uploaded file. + thread_ts: + type: str + description: + - Optional timestamp of parent message to thread this message, see U(https://api.slack.com/docs/message-threading). """ EXAMPLES = r""" @@ -293,6 +298,7 @@ alt_text: '' snippet_type: '' title: '' + thread_ts: '' """ import re @@ -474,9 +480,10 @@ def do_notify_slack(module, domain, token, payload): else: return {'webhook': 'ok'} + def get_channel_id(module, token, channel_name): url = SLACK_CONVERSATIONS_LIST_WEBAPI - headers = {"Authorization": f"Bearer {token}"} + headers = {"Authorization": "Bearer " + token} params = { "types": "public_channel,private_channel,mpim,im", "limit": 1000, @@ -487,22 +494,22 @@ def get_channel_id(module, token, channel_name): if cursor: params["cursor"] = cursor query = urlencode(params) - full_url = f"{url}?{query}" + full_url = "%s?%s" % (url, query) response, info = fetch_url(module, full_url, headers=headers, method="GET") status = info.get("status") if status != 200: error_msg = info.get("msg", "Unknown error") module.fail_json( - msg=f"Failed to retrieve channels: {error_msg} (HTTP {status})" + msg="Failed to retrieve channels: %s (HTTP %s)" % (error_msg, status) ) try: response_body = response.read().decode("utf-8") if response else "" data = json.loads(response_body) - except json.JSONDecodeError as e: - module.fail_json(msg=f"JSON decode error: {e}") + except ValueError as e: + module.fail_json(msg="JSON decode error: %s" % str(e)) if not data.get("ok"): error = data.get("error", "Unknown error") - module.fail_json(msg=f"Slack API error: {error}") + module.fail_json(msg="Slack API error: %s" % error) channels = data.get("channels", []) for channel in channels: if channel.get("name") == channel_name: @@ -511,52 +518,45 @@ def get_channel_id(module, token, channel_name): cursor = data.get("response_metadata", {}).get("next_cursor") if not cursor: break - module.fail_json(msg=f"Channel named '{channel_name}' not found.") + module.fail_json(msg="Channel named '%s' not found." % channel_name) def upload_file_to_slack(module, token, channel, file_upload): try: file_path = file_upload["path"] if not os.path.exists(file_path): - module.fail_json(msg=f"File not found: {file_path}") + module.fail_json(msg="File not found: %s" % file_path) # Step 1: Get upload URL url = SLACK_GET_UPLOAD_URL_EXTERNAL headers = { - "Authorization": f"Bearer {token}", + "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", } - params = urlencode( - { - "filename": file_upload.get("filename", os.path.basename(file_path)), - "length": os.path.getsize(file_path), - **( - {"alt_text": file_upload.get("alt_text")} - if file_upload.get("alt_text") - else {} - ), - **( - {"snippet_type": file_upload.get("snippet_type")} - if file_upload.get("snippet_type") - else {} - ), - } - ) + params = { + "filename": file_upload.get("filename", os.path.basename(file_path)), + "length": os.path.getsize(file_path), + } + if file_upload.get("alt_text"): + params["alt_text"] = file_upload.get("alt_text") + if file_upload.get("snippet_type"): + params["snippet_type"] = file_upload.get("snippet_type") + params = urlencode(params) response, info = fetch_url( - module, f"{url}?{params}", headers=headers, method="GET" + module, "%s?%s" % (url, params), headers=headers, method="GET" ) if info["status"] != 200: module.fail_json( - msg=f"Error retrieving upload URL: {info['msg']} (HTTP {info['status']})" + msg="Error retrieving upload URL: %s (HTTP %s)" % (info['msg'], info['status']) ) try: upload_url_data = json.load(response) - except json.JSONDecodeError: + except ValueError: module.fail_json( - msg=f"The Slack API response is not valid JSON: {response.read()}" + msg="The Slack API response is not valid JSON: %s" % response.read() ) if not upload_url_data.get("ok"): module.fail_json( - msg=f"Failed to retrieve upload URL: {upload_url_data.get('error')}" + msg="Failed to retrieve upload URL: %s" % upload_url_data.get('error') ) upload_url = upload_url_data["upload_url"] file_id = upload_url_data["file_id"] @@ -573,39 +573,29 @@ def upload_file_to_slack(module, token, channel, file_upload): ) if info["status"] != 200: module.fail_json( - msg=f"Error during file upload: {info['msg']} (HTTP {info['status']})" + msg="Error during file upload: %s (HTTP %s)" % (info['msg'], info['status']) ) - except FileNotFoundError: - module.fail_json(msg=f"The file {file_path} is not found.") + except IOError: + module.fail_json(msg="The file %s is not found." % file_path) # Step 3: Complete upload complete_url = SLACK_COMPLETE_UPLOAD_EXTERNAL - files_data = json.dumps( - { - "files": [ - { - "id": file_id, - **( - {"title": file_upload.get("title")} - if file_upload.get("title") - else {} - ), - } - ], - **( - {"initial_comment": file_upload.get("initial_comment")} - if file_upload.get("initial_comment") - else {} - ), - **( - {"thread_ts": file_upload.get("thread_ts")} - if file_upload.get("thread_ts") - else {} - ), - "channel_id": get_channel_id(module, token, channel), - } - ) + files_dict = { + "files": [ + { + "id": file_id, + } + ], + "channel_id": get_channel_id(module, token, channel), + } + if file_upload.get("title"): + files_dict["files"][0]["title"] = file_upload.get("title") + if file_upload.get("initial_comment"): + files_dict["initial_comment"] = file_upload.get("initial_comment") + if file_upload.get("thread_ts"): + files_dict["thread_ts"] = file_upload.get("thread_ts") + files_data = json.dumps(files_dict) headers = { - "Authorization": f"Bearer {token}", + "Authorization": "Bearer " + token, "Content-Type": "application/json", } try: @@ -614,18 +604,18 @@ def upload_file_to_slack(module, token, channel, file_upload): ) if info["status"] != 200: module.fail_json( - msg=f"Error during upload completion: {info['msg']} (HTTP {info['status']})" + msg="Error during upload completion: %s (HTTP %s)" % (info['msg'], info['status']) ) upload_url_data = json.load(response) - except json.JSONDecodeError: + except ValueError: module.fail_json( - msg=f"The Slack API response is not valid JSON: {response.read()}" + msg="The Slack API response is not valid JSON: %s" % response.read() ) if not upload_url_data.get("ok"): - module.fail_json(msg=f"Failed to complete the upload: {upload_url_data}") + module.fail_json(msg="Failed to complete the upload: %s" % upload_url_data) return upload_url_data except Exception as e: - module.fail_json(msg=f"Error uploading file: {str(e)}") + module.fail_json(msg="Error uploading file: %s" % str(e)) def main(): @@ -647,14 +637,16 @@ def main(): blocks=dict(type='list', elements='dict'), message_id=dict(type='str'), prepend_hash=dict(type='str', choices=['always', 'never', 'auto']), - upload_file=dict(type="dict", options=dict( - path=dict(type="str", required=True), - alt_text=dict(type="str"), - snippet_type=dict(type="str"), - initial_comment=dict(type="str"), - thread_ts=dict(type="str"), - title=dict(type="str") - ) + upload_file=dict( + type="dict", + options=dict( + path=dict(type="str", required=True), + alt_text=dict(type="str"), + snippet_type=dict(type="str"), + initial_comment=dict(type="str"), + thread_ts=dict(type="str"), + title=dict(type="str"), + ) ), ), supports_check_mode=True, @@ -688,7 +680,7 @@ def main(): upload_response=upload_response, ) except Exception as e: - module.fail_json(msg=f"Failed to upload file: {str(e)}") + module.fail_json(msg="Failed to upload file: %s" % str(e)) if prepend_hash is None: module.deprecate(