diff --git a/changelogs/fragments/702-slack-support-for-blocks.yaml b/changelogs/fragments/702-slack-support-for-blocks.yaml new file mode 100644 index 0000000000..40c2a3452e --- /dev/null +++ b/changelogs/fragments/702-slack-support-for-blocks.yaml @@ -0,0 +1,2 @@ +minor_changes: + - slack - add support for sending messages built with block kit (https://github.com/ansible-collections/community.general/issues/380). diff --git a/plugins/modules/notification/slack.py b/plugins/modules/notification/slack.py index c9217c7867..be569c4b95 100644 --- a/plugins/modules/notification/slack.py +++ b/plugins/modules/notification/slack.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# (c) 2020, Lee Goolsbee # (c) 2020, Michal Middleton # (c) 2017, Steve Pletcher # (c) 2016, René Moser @@ -45,8 +46,7 @@ options: 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 inted to receive and use - thread_id. + 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: @@ -100,7 +100,14 @@ options: attachments: description: - Define a list of attachments. This list mirrors the Slack JSON API. - - For more information, see also in the (U(https://api.slack.com/docs/attachments)). + - 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 """ EXAMPLES = """ @@ -153,6 +160,27 @@ EXAMPLES = """ 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 @@ -206,8 +234,24 @@ def escape_quotes(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(module, text, channel, thread_id, username, icon_url, icon_emoji, link_names, - parse, color, attachments): + parse, color, attachments, blocks): payload = {} if color == "normal" and text is not None: payload = dict(text=escape_quotes(text)) @@ -237,7 +281,7 @@ def build_payload_for_slack(module, text, channel, thread_id, username, icon_url payload['attachments'] = [] if attachments is not None: - keys_to_escape = [ + attachment_keys_to_escape = [ 'title', 'text', 'author_name', @@ -245,7 +289,7 @@ def build_payload_for_slack(module, text, channel, thread_id, username, icon_url 'fallback', ] for attachment in attachments: - for key in keys_to_escape: + for key in attachment_keys_to_escape: if key in attachment: attachment[key] = escape_quotes(attachment[key]) @@ -254,6 +298,13 @@ def build_payload_for_slack(module, text, channel, thread_id, username, icon_url 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) + payload = module.jsonify(payload) return payload @@ -310,7 +361,8 @@ def main(): parse=dict(type='str', default=None, choices=['none', 'full']), validate_certs=dict(default=True, type='bool'), color=dict(type='str', default='normal'), - attachments=dict(type='list', required=False, default=None) + attachments=dict(type='list', required=False, default=None), + blocks=dict(type='list', elements='dict'), ) ) @@ -326,6 +378,7 @@ def main(): parse = module.params['parse'] color = module.params['color'] attachments = module.params['attachments'] + blocks = module.params['blocks'] color_choices = ['normal', 'good', 'warning', 'danger'] if color not in color_choices and not is_valid_hex_color(color): @@ -333,7 +386,7 @@ def main(): "or any valid hex value with length 3 or 6." % color_choices) payload = build_payload_for_slack(module, text, channel, thread_id, username, icon_url, icon_emoji, link_names, - parse, color, attachments) + parse, color, attachments, blocks) slack_response = do_notify_slack(module, domain, token, payload) if 'ok' in slack_response: diff --git a/tests/unit/plugins/modules/notification/test_slack.py b/tests/unit/plugins/modules/notification/test_slack.py index 2338ef544b..209cfd235f 100644 --- a/tests/unit/plugins/modules/notification/test_slack.py +++ b/tests/unit/plugins/modules/notification/test_slack.py @@ -88,6 +88,43 @@ class TestSlackModule(ModuleTestCase): assert call_data['thread_ts'] == '100.00' assert fetch_url_mock.call_args[1]['url'] == "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ" + def test_message_with_blocks(self): + """tests sending a message with blocks""" + set_module_args({ + 'token': 'XXXX/YYYY/ZZZZ', + 'msg': 'test', + 'blocks': [{ + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': '*test*' + }, + 'accessory': { + 'type': 'image', + 'image_url': 'https://www.ansible.com/favicon.ico', + 'alt_text': 'test' + } + }, { + 'type': 'section', + 'text': { + 'type': 'plain_text', + 'text': 'test', + 'emoji': True + } + }] + }) + + with patch.object(slack, "fetch_url") as fetch_url_mock: + fetch_url_mock.return_value = (None, {"status": 200}) + with self.assertRaises(AnsibleExitJson): + self.module.main() + + self.assertTrue(fetch_url_mock.call_count, 1) + 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" + def test_message_with_invalid_color(self): """tests sending invalid color value to module""" set_module_args({