diff --git a/plugins/modules/slack.py b/plugins/modules/slack.py index b4e637f591d..6676e1154f2 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 @@ -251,19 +252,32 @@ 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: '' """ import re +import os +import json 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 - OLD_SLACK_INCOMING_WEBHOOK = 'https://%s/services/hooks/incoming-webhook?token=%s' SLACK_INCOMING_WEBHOOK = 'https://hooks.slack.com/services/%s' 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. &, <, >). escape_table = { @@ -427,6 +441,123 @@ 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( @@ -447,6 +578,14 @@ def main(): blocks=dict(type='list', elements='dict'), message_id=dict(type='str'), prepend_hash=dict(type='str', default='auto', 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, ) @@ -466,6 +605,23 @@ 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)}") color_choices = ['normal', 'good', 'warning', 'danger'] if color not in color_choices and not is_valid_hex_color(color):