diff options
Diffstat (limited to 'ansible_collections/community/general/plugins/modules/slack.py')
-rw-r--r-- | ansible_collections/community/general/plugins/modules/slack.py | 521 |
1 files changed, 521 insertions, 0 deletions
diff --git a/ansible_collections/community/general/plugins/modules/slack.py b/ansible_collections/community/general/plugins/modules/slack.py new file mode 100644 index 000000000..4e26f1973 --- /dev/null +++ b/ansible_collections/community/general/plugins/modules/slack.py @@ -0,0 +1,521 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# 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> +# Copyright (c) 2016, René Moser <mail@renemoser.net> +# Copyright (c) 2015, Stefan Berggren <nsg@nsg.cc> +# Copyright (c) 2014, Ramon de la Fuente <ramon@delafuente.nl> +# +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = """ +module: slack +short_description: Send Slack notifications +description: + - The C(slack) module sends notifications to U(http://slack.com) via the Incoming WebHook integration +author: "Ramon de la Fuente (@ramondelafuente)" +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + domain: + type: str + description: + - Slack (sub)domain for your environment without protocol. (i.e. + C(example.slack.com)) In 1.8 and beyond, this is deprecated and may + be ignored. See token documentation for information. + token: + type: str + description: + - Slack integration token. This authenticates you to the slack service. + Make sure to use the correct type of token, depending on what method you use. + - "Webhook token: + Prior to 1.8, a token looked like C(3Ffe373sfhRE6y42Fg3rvf4GlK). In + 1.8 and above, ansible adapts to the new slack API where tokens look + like C(G922VJP24/D921DW937/3Ffe373sfhRE6y42Fg3rvf4GlK). If tokens + are in the new format then slack will ignore any value of domain. If + the token is in the old format the domain is required. Ansible has no + control of when slack will get rid of the old API. When slack does + that the old format will stop working. ** Please keep in mind the tokens + are not the API tokens but are the webhook tokens. In slack these are + found in the webhook URL which are obtained under the apps and integrations. + The incoming webhooks can be added in that area. In some cases this may + be locked by your Slack admin and you must 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 C(xoxp-), C(xoxb-) + or C(xoxa-), eg. C(xoxb-1234-56789abcdefghijklmnop). 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: + type: str + description: + - Message to send. Note that the module does not handle escaping characters. + Plain-text angle brackets and ampersands should be converted to HTML entities (e.g. & to &) before sending. + See Slack's documentation (U(https://api.slack.com/docs/message-formatting)) for more. + channel: + type: str + description: + - Channel to send the message to. If absent, the message goes to the channel selected for the I(token). + thread_id: + description: + - Optional. Timestamp of parent message to thread this message. https://api.slack.com/docs/message-threading + type: str + message_id: + description: + - Optional. Message ID to edit, instead of posting a new message. + - If supplied I(channel_id) must be in form of C(C0xxxxxxx). use C({{ slack_response.channel_id }}) to get I(channel_id) from previous task run. + - Corresponds to C(ts) in the Slack API (U(https://api.slack.com/messaging/modifying)). + type: str + version_added: 1.2.0 + username: + type: str + description: + - This is the sender of the message. + default: "Ansible" + icon_url: + type: str + description: + - URL for the message sender's icon (default C(https://docs.ansible.com/favicon.ico)) + default: https://docs.ansible.com/favicon.ico + icon_emoji: + type: str + description: + - Emoji for the message sender. See Slack documentation for options. + (if I(icon_emoji) is set, I(icon_url) will not be used) + link_names: + type: int + description: + - Automatically create links for channels and usernames in I(msg). + default: 1 + choices: + - 1 + - 0 + parse: + type: str + description: + - Setting for the message parser at Slack + choices: + - 'full' + - 'none' + validate_certs: + description: + - If C(false), SSL certificates will not be validated. This should only be used + on personally controlled sites using self-signed certificates. + type: bool + default: true + color: + type: str + description: + - Allow text to use default colors - use the default of 'normal' to not send a custom color bar at the start of the message. + - Allowed values for color can be one of 'normal', 'good', 'warning', 'danger', any valid 3 digit or 6 digit hex color value. + - Specifying value in hex is supported since Ansible 2.8. + default: 'normal' + attachments: + type: list + elements: dict + description: + - Define a list of attachments. This list mirrors the Slack JSON API. + - For more information, see U(https://api.slack.com/docs/attachments). + blocks: + description: + - Define a list of blocks. This list mirrors the Slack JSON API. + - For more information, see U(https://api.slack.com/block-kit). + type: list + elements: dict + version_added: 1.0.0 + prepend_hash: + type: str + description: + - Setting for automatically prepending a C(#) symbol on the passed in I(channel_id). + - The C(auto) method prepends a C(#) unless I(channel_id) starts with one of C(#), C(@), C(C0), C(GF), C(G0), C(CP). + These prefixes only cover a small set of the prefixes that should not have a C(#) prepended. + Since an exact condition which I(channel_id) values must not have the C(#) prefix is not known, + the value C(auto) for this option will be deprecated in the future. It is best to explicitly set + I(prepend_hash=always) or I(prepend_hash=never) to obtain the needed behavior. + choices: + - 'always' + - 'never' + - 'auto' + default: 'auto' + version_added: 6.1.0 +""" + +EXAMPLES = """ +- name: Send notification message via Slack + community.general.slack: + token: thetoken/generatedby/slack + msg: '{{ inventory_hostname }} completed' + delegate_to: localhost + +- name: Send notification message via Slack all options + community.general.slack: + token: thetoken/generatedby/slack + msg: '{{ inventory_hostname }} completed' + channel: '#ansible' + thread_id: '1539917263.000100' + username: 'Ansible on {{ inventory_hostname }}' + icon_url: http://www.example.com/some-image-file.png + link_names: 0 + parse: 'none' + delegate_to: localhost + +- name: Insert a color bar in front of the message for visibility purposes and use the default webhook icon and name configured in Slack + community.general.slack: + token: thetoken/generatedby/slack + msg: '{{ inventory_hostname }} is alive!' + color: good + username: '' + icon_url: '' + +- name: Insert a color bar in front of the message with valid hex color value + community.general.slack: + token: thetoken/generatedby/slack + msg: 'This message uses color in hex value' + color: '#00aacc' + username: '' + icon_url: '' + +- name: Use the attachments API + community.general.slack: + token: thetoken/generatedby/slack + attachments: + - text: Display my system load on host A and B + color: '#ff00dd' + title: System load + fields: + - title: System A + value: "load average: 0,74, 0,66, 0,63" + short: true + - title: System B + value: 'load average: 5,16, 4,64, 2,43' + short: true + +- name: Use the blocks API + community.general.slack: + token: thetoken/generatedby/slack + blocks: + - type: section + text: + type: mrkdwn + text: |- + *System load* + Display my system load on host A and B + - type: context + elements: + - type: mrkdwn + text: |- + *System A* + load average: 0,74, 0,66, 0,63 + - type: mrkdwn + text: |- + *System B* + load average: 5,16, 4,64, 2,43 + +- name: Send a message with a link using Slack markup + community.general.slack: + token: thetoken/generatedby/slack + msg: We sent this message using <https://www.ansible.com|Ansible>! + +- name: Send a message with angle brackets and ampersands + community.general.slack: + token: thetoken/generatedby/slack + msg: This message has <brackets> & ampersands in plain text. + +- name: Initial Threaded Slack message + community.general.slack: + channel: '#ansible' + token: xoxb-1234-56789abcdefghijklmnop + 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 + thread_id: "{{ slack_response['ts'] }}" + color: good + msg: 'And this is my threaded response!' + +- name: Send a message to be edited later on + community.general.slack: + token: thetoken/generatedby/slack + channel: '#ansible' + msg: Deploying something... + register: slack_response +- name: Edit message + community.general.slack: + token: thetoken/generatedby/slack + # The 'channel' option does not accept the channel name. It must use the 'channel_id', + # which can be retrieved for example from 'slack_response' from the previous task. + channel: "{{ slack_response.channel }}" + msg: Deployment complete! + message_id: "{{ slack_response.ts }}" +""" + +import re +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' + +# 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 = { + '"': "\"", + "'": "\'", +} + + +def is_valid_hex_color(color_choice): + if re.match(r'^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', color_choice): + return True + return False + + +def escape_quotes(text): + """Backslash any quotes within text.""" + return "".join(escape_table.get(c, c) for c in text) + + +def recursive_escape_quotes(obj, keys): + """Recursively escape quotes inside supplied keys inside block kit objects""" + if isinstance(obj, dict): + escaped = {} + for k, v in obj.items(): + if isinstance(v, str) and k in keys: + escaped[k] = escape_quotes(v) + else: + escaped[k] = recursive_escape_quotes(v, keys) + elif isinstance(obj, list): + escaped = [recursive_escape_quotes(v, keys) for v in obj] + else: + escaped = obj + return escaped + + +def build_payload_for_slack(text, channel, thread_id, username, icon_url, icon_emoji, link_names, + parse, color, attachments, blocks, message_id, prepend_hash): + payload = {} + if color == "normal" and text is not None: + payload = dict(text=escape_quotes(text)) + elif text is not None: + # With a custom color we have to set the message as attachment, and explicitly turn markdown parsing on for it. + payload = dict(attachments=[dict(text=escape_quotes(text), color=color, mrkdwn_in=["text"])]) + if channel is not None: + if prepend_hash == 'auto': + if channel.startswith(('#', '@', 'C0', 'GF', 'G0', 'CP')): + payload['channel'] = channel + else: + payload['channel'] = '#' + channel + elif prepend_hash == 'always': + payload['channel'] = '#' + channel + elif prepend_hash == 'never': + payload['channel'] = channel + if thread_id is not None: + payload['thread_ts'] = thread_id + if username is not None: + payload['username'] = username + if icon_emoji is not None: + payload['icon_emoji'] = icon_emoji + else: + payload['icon_url'] = icon_url + if link_names is not None: + payload['link_names'] = link_names + if parse is not None: + payload['parse'] = parse + if message_id is not None: + payload['ts'] = message_id + + if attachments is not None: + if 'attachments' not in payload: + payload['attachments'] = [] + + if attachments is not None: + attachment_keys_to_escape = [ + 'title', + 'text', + 'author_name', + 'pretext', + 'fallback', + ] + for attachment in attachments: + for key in attachment_keys_to_escape: + if key in attachment: + attachment[key] = escape_quotes(attachment[key]) + + if 'fallback' not in attachment: + attachment['fallback'] = attachment['text'] + + payload['attachments'].append(attachment) + + if blocks is not None: + block_keys_to_escape = [ + 'text', + 'alt_text' + ] + payload['blocks'] = recursive_escape_quotes(blocks, block_keys_to_escape) + + return payload + + +def get_slack_message(module, token, channel, ts): + headers = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Accept': 'application/json', + 'Authorization': 'Bearer ' + token + } + qs = urlencode({ + 'channel': channel, + 'ts': ts, + 'limit': 1, + 'inclusive': 'true', + }) + url = SLACK_CONVERSATIONS_HISTORY_WEBAPI + '?' + qs + response, info = fetch_url(module=module, url=url, headers=headers, method='GET') + if info['status'] != 200: + module.fail_json(msg="failed to get slack message") + data = module.from_json(response.read()) + if len(data['messages']) < 1: + module.fail_json(msg="no messages matching ts: %s" % ts) + if len(data['messages']) > 1: + module.fail_json(msg="more than 1 message matching ts: %s" % ts) + return data['messages'][0] + + +def do_notify_slack(module, domain, token, payload): + use_webapi = False + if token.count('/') >= 2: + # New style webhook token + slack_uri = SLACK_INCOMING_WEBHOOK % token + elif re.match(r'^xox[abp]-\S+$', token): + slack_uri = SLACK_UPDATEMESSAGE_WEBAPI if 'ts' in payload else SLACK_POSTMESSAGE_WEBAPI + use_webapi = True + else: + if not domain: + module.fail_json(msg="Slack has updated its webhook API. You need to specify a token of the form " + "XXXX/YYYY/ZZZZ in your playbook") + slack_uri = OLD_SLACK_INCOMING_WEBHOOK % (domain, token) + + headers = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Accept': 'application/json', + } + if use_webapi: + headers['Authorization'] = 'Bearer ' + token + + data = module.jsonify(payload) + response, info = fetch_url(module=module, url=slack_uri, headers=headers, method='POST', data=data) + + if info['status'] != 200: + if use_webapi: + obscured_incoming_webhook = slack_uri + else: + obscured_incoming_webhook = SLACK_INCOMING_WEBHOOK % '[obscured]' + module.fail_json(msg=" failed to send %s to %s: %s" % (data, obscured_incoming_webhook, info['msg'])) + + # each API requires different handling + if use_webapi: + return module.from_json(response.read()) + else: + return {'webhook': 'ok'} + + +def main(): + module = AnsibleModule( + argument_spec=dict( + domain=dict(type='str'), + token=dict(type='str', required=True, no_log=True), + msg=dict(type='str'), + channel=dict(type='str'), + thread_id=dict(type='str'), + username=dict(type='str', default='Ansible'), + icon_url=dict(type='str', default='https://docs.ansible.com/favicon.ico'), + icon_emoji=dict(type='str'), + link_names=dict(type='int', default=1, choices=[0, 1]), + parse=dict(type='str', choices=['none', 'full']), + validate_certs=dict(default=True, type='bool'), + color=dict(type='str', default='normal'), + attachments=dict(type='list', elements='dict'), + blocks=dict(type='list', elements='dict'), + message_id=dict(type='str'), + prepend_hash=dict(type='str', default='auto', choices=['always', 'never', 'auto']), + ), + supports_check_mode=True, + ) + + domain = module.params['domain'] + token = module.params['token'] + text = module.params['msg'] + channel = module.params['channel'] + thread_id = module.params['thread_id'] + username = module.params['username'] + icon_url = module.params['icon_url'] + icon_emoji = module.params['icon_emoji'] + link_names = module.params['link_names'] + parse = module.params['parse'] + color = module.params['color'] + attachments = module.params['attachments'] + blocks = module.params['blocks'] + message_id = module.params['message_id'] + prepend_hash = module.params['prepend_hash'] + + color_choices = ['normal', 'good', 'warning', 'danger'] + if color not in color_choices and not is_valid_hex_color(color): + module.fail_json(msg="Color value specified should be either one of %r " + "or any valid hex value with length 3 or 6." % color_choices) + + changed = True + + # if updating an existing message, we can check if there's anything to update + if message_id is not None: + changed = False + msg = get_slack_message(module, token, channel, message_id) + for key in ('icon_url', 'icon_emoji', 'link_names', 'color', 'attachments', 'blocks'): + if msg.get(key) != module.params.get(key): + changed = True + break + # if check mode is active, we shouldn't do anything regardless. + # if changed=False, we don't need to do anything, so don't do it. + if module.check_mode or not changed: + module.exit_json(changed=changed, ts=msg['ts'], channel=msg['channel']) + elif module.check_mode: + module.exit_json(changed=changed) + + payload = build_payload_for_slack(text, channel, thread_id, username, icon_url, icon_emoji, link_names, + parse, color, attachments, blocks, 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']) + else: + # 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 __name__ == '__main__': + main() |