def handler(event, context):
    body = event['body']
    params = parse_qs(body)
    channel_id = params['channel_id'][0]
    user_id = params['user_id'][0]

    if 'text' not in params:
        return cmd_help()

    res = token_table.get_item(Key={'user_id': user_id})
    if 'Item' not in res:
        return please_install()

    encrypt_token = res['Item']['token']
    blob_token = base64.b64decode(encrypt_token)
    kms_res = kms_client.decrypt(CiphertextBlob=blob_token)
    decrypted_token = kms_res['Plaintext'].decode('utf-8')

    input_text = params['text'][0].split()
    TargetLanguageCode = input_text[0]
    main_text = input_text[1]
    response = translate.translate_text(Text=main_text,
                                        SourceLanguageCode='auto',
                                        TargetLanguageCode=TargetLanguageCode)

    client = WebClient(token=decrypted_token)
    client.chat_postMessage(channel=channel_id,
                            text=response['TranslatedText'])
    return {
        'statusCode': HTTPStatus.OK,
        'body': json.dumps({'text': 'Input: ' + input_text[1]}),
        'headers': {
            'Content-Type': 'application/json'
        }
    }
Esempio n. 2
0
def send_message():
    slack_info = SlackInfo.query.filter_by(
        team_name=request.get_json()["team"]).first()

    if slack_info is None:
        return jsonify({"type": "not_found", "text": "Team not found"}), 400

    slack = WebClient(token=slack_info.bot_access_token)

    try:
        slack.chat_postMessage(channel=request.get_json()['channel'],
                               text=request.get_json()['text'])
    except SlackApiError as error:
        if error.response.data["error"] == "channel_not_found":
            return jsonify({
                "type": "not_found",
                "text": "Channel not found"
            }), 400
        elif error.response.data["error"] == "not_in_channel":
            return jsonify({
                "type": "not_in_channel",
                "text": "The bot is not a member of the channel"
            }), 400

    return make_response("", 200)
Esempio n. 3
0
class SlackClient:
    def __init__(self):
        self.client = WebClient(token=SLACK_BOT_TOKEN)

    def _raw_send_slack_message(self, channel: str,
                                message: str) -> Union[Future, SlackResponse]:
        return self.client.chat_postMessage(
            channel=channel,
            text=message,
        )

    def send_slack_message(self, channel: str,
                           message: str) -> Union[Future, SlackResponse]:
        while True:
            try:
                return self._raw_send_slack_message(channel, message)
            except SlackApiError as e:
                if e.response["error"] == "ratelimited":
                    delay = int(e.response.headers['Retry-After'])
                    print(f"Rate limited. Retrying in {delay} seconds")
                    time.sleep(delay)
                    return self._raw_send_slack_message(channel, message)
                else:
                    # other errors
                    raise e
Esempio n. 4
0
 def sendMessage(msg, test):
   SLACK_BOT_TOKEN = os.environ['TAT_alerts_slack_bot_token']
   slack_client = WebClient(SLACK_BOT_TOKEN)
   logging.debug("authorized slack client")
   # make the POST request through the python slack client
   
   channelname = "#tat-alerts"
   if test:
       channelname = channelname + '-test'
   # check if the request was a success
   try:
     slack_client.chat_postMessage(
       channel=channelname,
       text=msg
     )
   except SlackApiError as e:
     logging.error('Request to Slack API Failed: {}.'.format(e.response.status_code))
     logging.error(e.response)
Esempio n. 5
0
def on_echo_command():
    slack_info = SlackInfo.query.filter_by(
        team_id=request.values["team_id"]).first()

    slack = WebClient(slack_info.bot_access_token)
    pprint(request.values.to_dict())

    try:
        slack.chat_postMessage(
            channel=request.values['channel_id'],
            text=f"{request.values['user_name']} said: {request.values['text']}"
        )
    except SlackApiError:
        post(url=request.values["response_url"],
             json={
                 "response_type": "ephemeral",
                 "text": "The bot *is not* a member of the channel"
             })

    return make_response('', 200)
Esempio n. 6
0
    def execute(self, dag, task, execution_date, run_time, url):
        client = WebClient(token=self._slack_token)

        slack_msg = f"""
                :red_circle: {' '.join(self._mentions)} Task Failed.
                *Task*: {task}
                *Dag*: {dag}
                *Execution Time*: {execution_date}
                *Running For*: {run_time} secs
                *Log Url*: {url}
                """

        response = client.chat_postMessage(link_names=1,
                                           channel=self._channel,
                                           text=slack_msg)

        print(response)
Esempio n. 7
0
class SlackIntegration(Integration):
    api_token = models.CharField(_("API token"), max_length=100)
    channel = models.CharField(_("channel"), max_length=100)
    notify_issue_create = models.BooleanField(_("notify on issue creation"), default=True)
    notify_issue_modify = models.BooleanField(_("notify on issue modification"), default=True)
    notify_comment_create = models.BooleanField(_("notify when there is a new comment"), default=True)
    notify_sprint_start = models.BooleanField(_("notify when a sprint is started"), default=True)
    notify_sprint_stop = models.BooleanField(_("notify when when a sprint is stopped"), default=True)
    slack = None

    class Meta:
        verbose_name = _("slackintegration")
        verbose_name_plural = _("slackintegrations")

    def __str__(self):
        return self.channel

    def connect_signals(self):
        if self.notify_issue_create:
            signals.create.connect(self.on_issue_signal, weak=False, sender=Issue)
        if self.notify_issue_modify:
            signals.modify.connect(self.on_issue_signal, weak=False, sender=Issue)
        if self.notify_comment_create:
            signals.create.connect(self.on_comment_signal, weak=False, sender=Comment)
        if self.notify_sprint_start:
            signals.start.connect(self.on_sprint_signal, weak=False, sender=Sprint)
        if self.notify_sprint_stop:
            signals.stop.connect(self.on_sprint_signal, weak=False, sender=Sprint)

    def disconnect_signals(self):
        if not self.notify_issue_create:
            signals.create.disconnect(self.on_issue_signal, sender=Issue)
        if not self.notify_issue_modify:
            signals.modify.disconnect(self.on_issue_signal, sender=Issue)
        if not self.notify_comment_create:
            signals.create.disconnect(self.on_comment_signal, sender=Comment)
        if not self.notify_sprint_start:
            signals.start.disconnect(self.on_sprint_signal, sender=Sprint)
        if not self.notify_sprint_stop:
            signals.stop.disconnect(self.on_sprint_signal, sender=Sprint)

    def save(self, *args, **kwargs):
        if not self.pk:
            self.connect_signals()
        else:
            self.disconnect_signals()
        super(SlackIntegration, self).save(*args, **kwargs)

    def slack_on(self):
        if not self.slack:
            self.slack = WebClient(token=self.api_token)

    def on_issue_signal(self, sender, signal, **kwargs):
        if "instance" not in kwargs or \
                kwargs['instance'].project != self.project:
            return
        self.slack_on()
        issue = kwargs['instance']
        user = kwargs['user']
        protocol = 'https://'
        if DEBUG:
            protocol = 'http://'
        title_link = protocol + HOST + issue.get_absolute_url()
        issue_title = issue.get_ticket_identifier() + ' ' + issue.title
        user_name = str(user)
        user_link = protocol + HOST + user.get_absolute_url()
        user_avatar = protocol + HOST + user.avatar.url

        if signal == signals.modify:
            text = '{} changed issue {}.'.format(user_name, issue_title)
            fields = []
            for field in kwargs['changed_data']:
                old_str = kwargs['changed_data'][field]
                new_field = issue.__getattribute__(field)
                new_str = str(new_field)
                # Ducktyping for RelatedManager
                if hasattr(new_field, "add") and \
                        hasattr(new_field, "create") and \
                        hasattr(new_field, "remove") and \
                        hasattr(new_field, "clear") and \
                        hasattr(new_field, "set"):
                    new_str = ", ".join([str(e) for e in new_field.all()])
                fields.append({
                    'title': Issue._meta.get_field(field).verbose_name.title(),
                    'value': '{} → {}'.format(old_str, new_str),
                    'short': True,
                })
                if field == 'description':
                    fields[-1]['short'] = False
            resp = self.slack.chat_postMessage(
                channel=self.channel,
                attachments=[{
                    'fallback': text,
                    'pretext': 'Issue changed:',
                    'title': issue_title,
                    'title_link': title_link,
                    'author_name': user_name,
                    'author_link': user_link,
                    'author_icon': user_avatar,
                    'fields': fields,
                    'color': 'good',
                }]
            )
        elif signal == signals.create:
            text = '{} created issue {}.'.format(user_name, issue_title)
            resp = self.slack.chat_postMessage(
                channel=self.channel,
                attachments=[{
                    'fallback': text,
                    'pretext': 'New Issue:',
                    'text': issue.description,
                    'title': issue_title,
                    'title_link': title_link,
                    'author_name': user_name,
                    'author_link': user_link,
                    'author_icon': user_avatar,
                    'color': 'good',
                }]
            )

    def on_comment_signal(self, sender, signal, **kwargs):
        if "instance" not in kwargs or \
                kwargs['instance'].issue.project != self.project:
            return
        self.slack_on()
        comment = kwargs['instance']
        user = kwargs['user']
        protocol = 'https://'
        if DEBUG:
            protocol = 'http://'
        title_link = protocol + HOST + comment.issue.get_absolute_url()
        issue_title = comment.issue.get_ticket_identifier() + ' ' + comment.issue.title
        user_link = protocol + HOST + user.get_absolute_url()
        user_avatar = protocol + HOST + user.avatar.url

        if signal == signals.create:
            text = '{} commented on "{}".'.format(str(user), issue_title)
            resp = self.slack.chat_postMessage(
                channel=self.channel,
                attachments=[{
                    'fallback': text,
                    'pretext': 'New comment:',
                    'text': comment.text,
                    'title': issue_title,
                    'title_link': title_link,
                    'author_name': str(user),
                    'author_link': user_link,
                    'author_icon': user_avatar,
                    'color': 'good',
                }]
            )

    def on_sprint_signal(self, sender, signal, **kwargs):
        if "instance" not in kwargs or \
                kwargs['instance'].project != self.project:
            return
        self.slack_on()
        sprint = kwargs['instance']
        user = kwargs['user']
        protocol = 'https://'
        if DEBUG:
            protocol = 'http://'
        title_link = protocol + HOST + reverse("backlog:backlog", kwargs={'project': self.project.name_short})
        title = "sprint {}".format(sprint.seqnum)
        user_link = protocol + HOST + user.get_absolute_url()
        user_avatar = protocol + HOST + user.avatar.url

        action = ""
        text = ""
        if signal == signals.start:
            action = "started"
            text = '{} started {}.'.format(str(user), title)
        elif signal == signals.stop:
            action = "stopped"
            text = '{} stopped {}.'.format(str(user), title)
        title = title.capitalize()

        date_format = "%D"
        fields = []
        fields.append({
            'title': _("Started"),
            'value': sprint.startdate.strftime(date_format),
            'short': True,
        })
        if action == "stopped":
            fields.append({
                'title': _("Stopped"),
                'value': sprint.enddate.strftime(date_format),
                'short': True,
            })
        if sprint.plandate:
            fields.append({
                'title': _("Planned end"),
                'value': sprint.plandate.strftime(date_format),
                'short': True,
            })

        resp = self.slack.chat_postMessage(
            channel=self.channel,
            attachments=[{
                'fallback': text,
                'pretext': 'Sprint {}:'.format(action),
                'text': '',
                'title': title,
                'title_link': title_link,
                'author_name': str(user),
                'author_link': user_link,
                'author_icon': user_avatar,
                'fields': fields,
                'color': 'good',
            }]
        )

    def user_has_write_permissions(self, user):
        return self.project.is_manager(user)

    def user_has_read_permissions(self, user):
        return self.project.user_has_read_permission(user)
Esempio n. 8
0
class SlackDispatcher(Dispatcher):
    """Dispatch messages and cards to Slack."""

    platform_name = "Slack"
    platform_slug = "slack"

    platform_color = "4A154B"  # Slack Aubergine

    command_prefix = settings.PLUGINS_CONFIG["nautobot_chatops"][
        "slack_slash_command_prefix"]
    """Prefix prepended to all commands, such as "/" or "!" in some clients."""
    def __init__(self, *args, **kwargs):
        """Init a SlackDispatcher."""
        super().__init__(*args, **kwargs)
        self.slack_client = WebClient(
            token=settings.PLUGINS_CONFIG["nautobot_chatops"]
            ["slack_api_token"])

    @classmethod
    @BACKEND_ACTION_LOOKUP.time()
    def platform_lookup(cls, item_type, item_name):
        """Call out to the chat platform to look up, e.g., a specific user ID by name.

        Args:
          item_type (str): One of "organization", "channel", "user"
          item_name (str): Uniquely identifying name of the given item.

        Returns:
          (str, None)
        """
        instance = cls(context=None)
        cursor = None
        if item_type == "organization":  # pylint: disable=no-else-raise
            # The admin_teams_list API requires admin access and only works under Enterprise
            raise NotImplementedError
        elif item_type == "channel":
            while True:
                try:
                    response = instance.slack_client.conversations_list(
                        cursor=cursor, limit=20, exclude_archived=True)
                except SlackApiError as err:
                    if err.response["error"] == "ratelimited":
                        delay = int(err.response.headers["Retry-After"])
                        time.sleep(delay)
                        continue
                    raise err
                for channel in response["channels"]:
                    if channel["name"] == item_name:
                        return channel["id"]
                cursor = response["response_metadata"]["next_cursor"]
                if not cursor:
                    break

        elif item_type == "user":
            while True:
                try:
                    response = instance.slack_client.users_list(cursor=cursor,
                                                                limit=20)
                except SlackApiError as err:
                    if err.response["error"] == "ratelimited":
                        delay = int(err.response.headers["Retry-After"])
                        time.sleep(delay)
                        continue
                    raise err
                for member in response["members"]:
                    if member["name"] == item_name:
                        return member["id"]
                cursor = response["response_metadata"]["next_cursor"]
                if not cursor:
                    break

        return None

    # More complex APIs for presenting structured data - these typically build on the more basic functions below

    def command_response_header(self,
                                command,
                                subcommand,
                                args,
                                description="information",
                                image_element=None):
        """Construct a consistently forwarded header including the command that was issued.

        Args:
          command (str): Primary command string
          subcommand (str): Secondary command string
          args (list): of tuples, either (arg_name, human_readable_value, literal_value) or (arg_name, literal_value)
          description (str): Short description of what information is contained in the response
          image_element (dict): As constructed by self.image_element()
        """
        fields = []
        for name, value, *_ in args:
            fields.append(self.markdown_element(self.bold(name)))
            fields.append(self.markdown_element(value))

        command = f"{self.command_prefix}{command}"

        block = {
            "type":
            "section",
            "text":
            self.markdown_element(
                f"Hey {self.user_mention()}, here is that {description} you requested\n"
                f"Shortcut: `{command} {subcommand} {' '.join(arg[-1] for arg in args)}`"
            ),
        }

        # Add to block "accessory" key if image_element exists. Otherwise do not
        if image_element:
            block["accessory"] = image_element

        # Slack doesn't like it if we send an empty fields list, we have to omit it entirely
        if fields:
            block["fields"] = fields

        return [block]

    # Send various content to the user or channel

    @BACKEND_ACTION_MARKDOWN.time()
    def send_markdown(self, message, ephemeral=False):
        """Send a Markdown-formatted text message to the user/channel specified by the context."""
        try:
            if ephemeral:
                self.slack_client.chat_postEphemeral(
                    channel=self.context.get("channel_id"),
                    user=self.context.get("user_id"),
                    text=message,
                )
            else:
                self.slack_client.chat_postMessage(
                    channel=self.context.get("channel_id"),
                    user=self.context.get("user_id"),
                    text=message,
                )
        except SlackClientError as slack_error:
            self.send_exception(slack_error)

    @BACKEND_ACTION_BLOCKS.time()
    def send_blocks(self,
                    blocks,
                    callback_id=None,
                    ephemeral=False,
                    modal=False,
                    title="Your attention please!"):
        """Send a series of formatting blocks to the user/channel specified by the context.

        Slack distinguishes between simple inline interactive elements and modal dialogs. Modals can contain multiple
        inputs in a single dialog; more importantly for our purposes, certain inputs (such as textentry) can ONLY
        be used in modals and will be rejected if we try to use them inline.

        Args:
          blocks (list): List of block contents as constructed by other dispatcher functions
          callback_id (str): Callback ID string such as "command subcommand arg1 arg2". Required if `modal` is True.
          ephemeral (bool): Whether to send this as an ephemeral message (only visible to the targeted user)
          modal (bool): Whether to send this as a modal dialog rather than an inline block.
          title (str): Title to include on a modal dialog.
        """
        logger.info("Sending blocks: %s", json.dumps(blocks, indent=2))
        try:
            if modal:
                if not callback_id:
                    self.send_error(
                        "Tried to create a modal dialog without specifying a callback_id"
                    )
                    return
                self.slack_client.views_open(
                    trigger_id=self.context.get("trigger_id"),
                    view={
                        "type":
                        "modal",
                        "title":
                        self.text_element(title),
                        "submit":
                        self.text_element("Submit"),
                        "blocks":
                        blocks,
                        # Embed the current channel information into to the modal as modals don't store this otherwise
                        "private_metadata":
                        json.dumps({
                            "channel_id":
                            self.context.get("channel_id"),
                        }),
                        "callback_id":
                        callback_id,
                    },
                )
            elif ephemeral:
                self.slack_client.chat_postEphemeral(
                    channel=self.context.get("channel_id"),
                    user=self.context.get("user_id"),
                    blocks=blocks,
                )
            else:
                self.slack_client.chat_postMessage(
                    channel=self.context.get("channel_id"),
                    user=self.context.get("user_id"),
                    blocks=blocks,
                )
        except SlackClientError as slack_error:
            self.send_exception(slack_error)

    @BACKEND_ACTION_SNIPPET.time()
    def send_snippet(self, text):
        """Send a longer chunk of text as a file snippet."""
        if self.context.get("channel_name") == "directmessage":
            channels = [self.context.get("user_id")]
        else:
            channels = [self.context.get("channel_id")]
        channels = ",".join(channels)
        logger.info("Sending snippet to %s: %s", channels, text)
        try:
            self.slack_client.files_upload(channels=channels, content=text)
        except SlackClientError as slack_error:
            self.send_exception(slack_error)

    def send_image(self, image_path):
        """Send an image as a file upload."""
        if self.context.get("channel_name") == "directmessage":
            channels = [self.context.get("user_id")]
        else:
            channels = [self.context.get("channel_id")]
        channels = ",".join(channels)
        logger.info("Sending image %s to %s", image_path, channels)
        self.slack_client.files_upload(channels=channels, file=image_path)

    def send_warning(self, message):
        """Send a warning message to the user/channel specified by the context."""
        self.send_markdown(f":warning: {self.bold(message)} :warning:",
                           ephemeral=True)

    def send_error(self, message):
        """Send an error message to the user/channel specified by the context."""
        self.send_markdown(f":warning: {self.bold(message)} :warning:",
                           ephemeral=True)

    def send_busy_indicator(self):
        """Send a "typing" indicator to show that work is in progress."""
        # Currently the Slack Events API does not support the "user_typing" event.
        # We're trying not to use the legacy Slack RTM API as it's deprecated.
        # So for now, we do nothing.
        pass

    def send_exception(self, exception):
        """Try to report an exception to the user."""
        self.slack_client.chat_postEphemeral(
            channel=self.context.get("channel_id"),
            user=self.context.get("user_id"),
            text=
            f"Sorry @{self.context.get('user_name')}, an error occurred :sob:\n```{exception}```",
        )

    def delete_message(self, response_url):
        """Delete a message that was previously sent."""
        WebhookClient(response_url).send_dict({"delete_original": "true"})

    # Prompt the user for various basic inputs

    def prompt_for_text(self,
                        action_id,
                        help_text,
                        label,
                        title="Your attention please!"):
        """Prompt the user to enter freeform text into a field.

        Args:
          action_id (str): Identifier string to attach to the "submit" action.
          help_text (str): Markdown string to display as help text.
          label (str): Label text to display adjacent to the text field.
          title (str): Title to include on the modal dialog.
        """
        textentry = {
            "type": "input",
            "block_id": action_id,
            "label": self.text_element(label),
            "element": {
                "type": "plain_text_input",
                "action_id": action_id,
                "placeholder": self.text_element(label)
            },
        }
        blocks = [self.markdown_block(help_text), textentry]
        # In Slack, a textentry element can ONLY be sent in a modal dialog
        return self.send_blocks(blocks,
                                callback_id=action_id,
                                ephemeral=True,
                                modal=True,
                                title=title)

    def prompt_from_menu(self,
                         action_id,
                         help_text,
                         choices,
                         default=(None, None),
                         confirm=False):
        """Prompt the user for a selection from a menu.

        Args:
          action_id (str): Identifier string to attach to the "submit" action.
          help_text (str): Markdown string to display as help text.
          choices (list): List of (display, value) tuples
          default (tuple): Default (display, valud) to pre-select.
          confirm (bool): If True, prompt the user to confirm their selection (if the platform supports this)
        """
        # TODO: Slack limits an option list to no more than 100 items. How can we work around this?
        if len(choices) > 100:
            self.send_warning(
                "More than 100 options are available. Slack limits us to the first 100."
            )
            choices = choices[:100]
        menu = self.select_element(action_id,
                                   choices,
                                   default=default,
                                   confirm=confirm)
        cancel_button = {
            "type": "button",
            "text": self.text_element("Cancel"),
            "action_id": "action",
            "value": "cancel",
        }

        blocks = [
            self.markdown_block(help_text),
            self.actions_block(action_id, [menu, cancel_button])
        ]
        return self.send_blocks(blocks, ephemeral=True)

    # Construct content piecemeal, mostly for use with send_blocks()

    def multi_input_dialog(self, command, sub_command, dialog_title,
                           dialog_list):
        """Provide several input fields on a single dialog.

        Args:
            command (str):  The top level command in use. (ex. net)
            sub_command (str): The command being invoked (ex. add-vlan)
            dialog_title (str): Title of the dialog box
            dialog_list (list):  List of dictionaries containing the dialog parameters. See Example below.

        Example:
            For a selection menu::

                {
                    type: "select",
                    label: "label",
                    choices: [("display 1", "value1"), ("display 2", "value 2")],
                    default: ("display 1", "value1"),
                    confirm: False
                }

            For a text dialog::

                {
                    type: "text",
                    label: "text displayed next to field"
                    default: "default-value"
                }

            Dictionary Fields

            - type: The type of object to create. Currently supported values: select, text
            - label: A text descriptor that will be placed next to the field
            - choices: A list of tuples which populates the choices in a dropdown selector
            - default: (optional) Default choice of a select menu or initial value to put in a text field.
            - confirm: (optional) If set to True, it will display a "Are you sure?" dialog upon submit.
        """
        blocks = []
        callback_id = f"{command} {sub_command}"
        for i, dialog in enumerate(dialog_list):
            action_id = f"param_{i}"
            if dialog["type"] == "select":
                menu = self.select_element(action_id, dialog["choices"],
                                           dialog.get("default", (None, None)),
                                           dialog.get("confirm", False))
                blocks.append(
                    self._input_block(action_id, dialog["label"], menu))
            if dialog["type"] == "text":
                textentry = {
                    "type": "plain_text_input",
                    "action_id": action_id,
                    "placeholder": self.text_element(dialog["label"]),
                    "initial_value": dialog.get("default", "") or "",
                }
                blocks.append(
                    self._input_block(action_id, dialog["label"], textentry))

        return self.send_blocks(blocks,
                                callback_id=callback_id,
                                modal=True,
                                ephemeral=False,
                                title=dialog_title)

    def user_mention(self):
        """Markup for a mention of the username/userid specified in our context."""
        return f"<@{self.context['user_id']}>"

    def bold(self, text):
        """Mark text as bold."""
        return f"*{text}*"

    def actions_block(self, block_id, actions):
        """Construct a block consisting of a set of action elements."""
        return {"type": "actions", "block_id": block_id, "elements": actions}

    def _input_block(self, block_id, label, element):
        """Construct a block consisting of Input elements."""
        text_obj = self.text_element(label)
        return {
            "type": "input",
            "block_id": block_id,
            "label": text_obj,
            "element": element
        }

    def markdown_block(self, text):
        """Construct a simple Markdown-formatted text block."""
        return {"type": "section", "text": self.markdown_element(text)}

    def image_element(self, url, alt_text=""):
        """Construct an image element that can be embedded in a block."""
        return {"type": "image", "image_url": url, "alt_text": alt_text}

    def markdown_element(self, text):
        """Construct a basic Markdown-formatted text element."""
        return {"type": "mrkdwn", "text": text}

    def select_element(self,
                       action_id,
                       choices,
                       default=(None, None),
                       confirm=False):
        """Construct a basic selection menu with the given choices.

        .. note:: Slack's select menu supports option groups, but others such as Adaptive Cards do not;
                  we have to stick to the lowest common denominator, which means no groups.

        TODO: maybe refactor this API so that Slack can use groups and other dispatchers just ignore the groups?

        Args:
          action_id (str): Identifying string to associate with this element
          choices (list): List of (display, value) tuples
          default (tuple: Default (display, value) to preselect
          confirm (bool): If true (and the platform supports it), prompt the user to confirm their selection
        """
        data = {
            "type":
            "static_select",
            "action_id":
            action_id,
            "placeholder":
            self.text_element("Select an option"),
            "options": [{
                "text": self.text_element(choice),
                "value": value
            } for choice, value in choices],
        }
        default_text, default_value = default
        if default_text and default_value:
            data["initial_option"] = {
                "text": self.text_element(default_text),
                "value": default_value
            }
        if confirm:
            data["confirm"] = {
                "title":
                self.text_element("Are you sure?"),
                "text":
                self.markdown_element(
                    "Are you *really* sure you want to do this?"),
                "confirm":
                self.text_element("Yes"),
                "deny":
                self.text_element("No"),
            }

        return data

    def text_element(self, text):
        """Construct a basic plaintext element."""
        return {"type": "plain_text", "text": text}