示例#1
0
文件: slack.py 项目: jperras/err
class SlackBackend(ErrBot):
    def __init__(self, config):
        super().__init__(config)
        identity = config.BOT_IDENTITY
        self.token = identity.get('token', None)
        if not self.token:
            log.fatal(
                'You need to set your token (found under "Bot Integration" on Slack) in '
                'the BOT_IDENTITY setting in your configuration. Without this token I '
                'cannot connect to Slack.'
            )
            sys.exit(1)
        self.sc = None  # Will be initialized in serve_once
        self.md = imtext()

    def api_call(self, method, data=None, raise_errors=True):
        """
        Make an API call to the Slack API and return response data.

        This is a thin wrapper around `SlackClient.server.api_call`.

        :param method:
            The API method to invoke (see https://api.slack.com/methods/).
        :param raise_errors:
            Whether to raise :class:`~SlackAPIResponseError` if the API
            returns an error
        :param data:
            A dictionary with data to pass along in the API request.
        :returns:
            The JSON-decoded API response
        :raises:
            :class:`~SlackAPIResponseError` if raise_errors is True and the
            API responds with `{"ok": false}`
        """
        if data is None:
            data = {}
        response = json.loads(self.sc.server.api_call(method, **data).decode('utf-8'))
        if raise_errors and not response['ok']:
            raise SlackAPIResponseError(
                "Slack API call to %s failed: %s" % (method, response['error']),
                error=response['error']
            )
        return response

    def serve_once(self):
        self.sc = SlackClient(self.token)
        log.info("Verifying authentication token")
        self.auth = self.api_call("auth.test", raise_errors=False)
        if not self.auth['ok']:
            raise SlackAPIResponseError(error="Couldn't authenticate with Slack. Server said: %s" % self.auth['error'])
        log.debug("Token accepted")
        self.bot_identifier = SlackIdentifier(self.sc, self.auth["user_id"])

        log.info("Connecting to Slack real-time-messaging API")
        if self.sc.rtm_connect():
            log.info("Connected")
            self.reset_reconnection_count()
            try:
                while True:
                    for message in self.sc.rtm_read():
                        if 'type' not in message:
                            log.debug("Ignoring non-event message: %s" % message)
                            continue

                        event_type = message['type']
                        event_handler = getattr(self, '_%s_event_handler' % event_type, None)
                        if event_handler is None:
                            log.debug("No event handler available for %s, ignoring this event" % event_type)
                            continue
                        try:
                            log.debug("Processing slack event: %s" % message)
                            event_handler(message)
                        except Exception:
                            log.exception("%s event handler raised an exception" % event_type)
                    time.sleep(1)
            except KeyboardInterrupt:
                log.info("Interrupt received, shutting down..")
                return True
            except:
                log.exception("Error reading from RTM stream:")
            finally:
                log.debug("Triggering disconnect callback")
                self.disconnect_callback()
        else:
            raise Exception('Connection failed, invalid token ?')

    def _hello_event_handler(self, event):
        """Event handler for the 'hello' event"""
        self.connect_callback()
        self.callback_presence(Presence(identifier=self.bot_identifier, status=ONLINE))

    def _presence_change_event_handler(self, event):
        """Event handler for the 'presence_change' event"""

        idd = SlackIdentifier(self.sc, event['user'])
        presence = event['presence']
        # According to https://api.slack.com/docs/presence, presence can
        # only be one of 'active' and 'away'
        if presence == 'active':
            status = ONLINE
        elif presence == 'away':
            status = AWAY
        else:
            log.error(
                "It appears the Slack API changed, I received an unknown presence type %s" % presence
            )
            status = ONLINE
        self.callback_presence(Presence(identifier=idd, status=status))

    def _team_join_event_handler(self, event):
        self.sc.parse_user_data((event['user'],))

    def _message_event_handler(self, event):
        """Event handler for the 'message' event"""
        channel = event['channel']
        if channel.startswith('C'):
            log.debug("Handling message from a public channel")
            message_type = 'groupchat'
        elif channel.startswith('G'):
            log.debug("Handling message from a private group")
            message_type = 'groupchat'
        elif channel.startswith('D'):
            log.debug("Handling message from a user")
            message_type = 'chat'
        else:
            log.warning("Unknown message type! Unable to handle")
            return
        subtype = event.get('subtype', None)

        if subtype == "message_deleted":
            log.debug("Message of type message_deleted, ignoring this event")
            return
        if subtype == "message_changed" and 'attachments' in event['message']:
            # If you paste a link into Slack, it does a call-out to grab details
            # from it so it can display this in the chatroom. These show up as
            # message_changed events with an 'attachments' key in the embedded
            # message. We should completely ignore these events otherwise we
            # could end up processing bot commands twice (user issues a command
            # containing a link, it gets processed, then Slack triggers the
            # message_changed event and we end up processing it again as a new
            # message. This is not what we want).
            log.debug(
                "Ignoring message_changed event with attachments, likely caused "
                "by Slack auto-expanding a link"
            )
            return

        if 'message' in event:
            text = event['message']['text']
            user = event['message']['user']
        else:
            text = event['text']
            user = event['user']

        text = re.sub("<[^>]*>", self.remove_angle_brackets_from_uris, text)

        msg = Message(text, type_=message_type)
        if message_type == 'chat':
            msg.frm = SlackIdentifier(self.sc, user, event['channel'])
            msg.to = SlackIdentifier(self.sc, self.username_to_userid(self.sc.server.username),
                                     event['channel'])
        else:
            msg.frm = SlackMUCOccupant(self.sc, user, event['channel'])
            msg.to = SlackMUCOccupant(self.sc, self.username_to_userid(self.sc.server.username),
                                      event['channel'])

        self.callback_message(msg)

    def userid_to_username(self, id_):
        """Convert a Slack user ID to their user name"""
        user = [user for user in self.sc.server.users if user.id == id_]
        if not user:
            raise UserDoesNotExistError("Cannot find user with ID %s" % id_)
        return user[0].name

    def username_to_userid(self, name):
        """Convert a Slack user name to their user ID"""
        user = [user for user in self.sc.server.users if user.name == name]
        if not user:
            raise UserDoesNotExistError("Cannot find user %s" % name)
        return user[0].id

    def channelid_to_channelname(self, id_):
        """Convert a Slack channel ID to its channel name"""
        channel = [channel for channel in self.sc.server.channels if channel.id == id_]
        if not channel:
            raise RoomDoesNotExistError("No channel with ID %s exists" % id_)
        return channel[0].name

    def channelname_to_channelid(self, name):
        """Convert a Slack channel name to its channel ID"""
        if name.startswith('#'):
            name = name[1:]
        channel = [channel for channel in self.sc.server.channels if channel.name == name]
        if not channel:
            raise RoomDoesNotExistError("No channel named %s exists" % name)
        return channel[0].id

    def channels(self, exclude_archived=True, joined_only=False):
        """
        Get all channels and groups and return information about them.

        :param exclude_archived:
            Exclude archived channels/groups
        :param joined_only:
            Filter out channels the bot hasn't joined
        :returns:
            A list of channel (https://api.slack.com/types/channel)
            and group (https://api.slack.com/types/group) types.

        See also:
          * https://api.slack.com/methods/channels.list
          * https://api.slack.com/methods/groups.list
        """
        response = self.api_call('channels.list', data={'exclude_archived': exclude_archived})
        channels = [channel for channel in response['channels']
                    if channel['is_member'] or not joined_only]

        response = self.api_call('groups.list', data={'exclude_archived': exclude_archived})
        # No need to filter for 'is_member' in this next call (it doesn't
        # (even exist) because leaving a group means you have to get invited
        # back again by somebody else.
        groups = [group for group in response['groups']]

        return channels + groups

    @lru_cache(50)
    def get_im_channel(self, id_):
        """Open a direct message channel to a user"""
        response = self.api_call('im.open', data={'user': id_})
        return response['channel']['id']

    def send_message(self, mess):
        super().send_message(mess)
        to_humanreadable = "<unknown>"
        try:
            if mess.type == 'groupchat':
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
            else:
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
                if to_channel_id.startswith('C'):
                    log.debug("This is a divert to private message, sending it directly to the user.")
                    to_channel_id = self.get_im_channel(self.username_to_userid(to_humanreadable))
            log.debug('Sending %s message to %s (%s)' % (mess.type, to_humanreadable, to_channel_id))
            body = self.md.convert(mess.body)
            log.debug('Message size: %d' % len(body))

            limit = min(self.bot_config.MESSAGE_SIZE_LIMIT, SLACK_MESSAGE_LIMIT)
            parts = self.prepare_message_body(body, limit)

            for part in parts:
                self.sc.rtm_send_message(to_channel_id, part)
        except Exception:
            log.exception(
                "An exception occurred while trying to send the following message "
                "to %s: %s" % (to_humanreadable, mess.body)
            )

    @staticmethod
    def prepare_message_body(body, size_limit):
        """
        Returns the parts of a message chunked and ready for sending.

        This is a staticmethod for easier testing.

        Args:
            body (str)
            size_limit (int): chunk the body into sizes capped at this maximum

        Returns:
            [str]

        """
        fixed_format = body.startswith('```')  # hack to fix the formatting
        parts = list(split_string_after(body, size_limit))

        if len(parts) == 1:
            # If we've got an open fixed block, close it out
            if parts[0].count('```') % 2 != 0:
                parts[0] += '\n```\n'
        else:
            for i, part in enumerate(parts):
                starts_with_code = part.startswith('```')

                # If we're continuing a fixed block from the last part
                if fixed_format and not starts_with_code:
                    parts[i] = '```\n' + part

                # If we've got an open fixed block, close it out
                if part.count('```') % 2 != 0:
                    parts[i] += '\n```\n'

        return parts

    def build_identifier(self, txtrep):
        """ #channelname/username
            or @username
        """
        log.debug("building an identifier from %s" % txtrep)
        txtrep = txtrep.strip()
        channelname = None

        if txtrep[0] == '@':
            username = txtrep[1:]
        elif txtrep[0] == '#':
            plainrep = txtrep[1:]
            if '/' not in txtrep:
                raise Exception("Unparseable slack identifier, " +
                                "should be #channelname/username or @username : '******'" % txtrep)
            channelname, username = plainrep.split('/')
        else:
            raise Exception("Unparseable slack identifier, " +
                            "should be #channelname/username or @username : '******'" % txtrep)

        userid = self.username_to_userid(username)
        if channelname:
            channelid = self.channelname_to_channelid(channelname)
            return SlackMUCOccupant(self.sc, userid, channelid)

        return SlackIdentifier(self.sc, userid, self.get_im_channel(userid))

    def build_reply(self, mess, text=None, private=False):
        msg_type = mess.type
        response = self.build_message(text)

        response.frm = self.bot_identifier
        response.to = mess.frm
        response.type = 'chat' if private else msg_type

        return response

    def shutdown(self):
        super().shutdown()

    @deprecated
    def join_room(self, room, username=None, password=None):
        return self.query_room(room)

    @property
    def mode(self):
        return 'slack'

    def query_room(self, room):
        """ Room can either be a name or a channelid """
        if room.startswith('C') or room.startswith('G'):
            return SlackRoom(channelid=room, bot=self)

        m = SLACK_CLIENT_CHANNEL_HYPERLINK.match(room)
        if m is not None:
            return SlackRoom(channelid=m.groupdict()['id'], bot=self)

        return SlackRoom(name=room, bot=self)

    def rooms(self):
        """
        Return a list of rooms the bot is currently in.

        :returns:
            A list of :class:`~SlackRoom` instances.
        """
        channels = self.channels(joined_only=True, exclude_archived=True)
        return [SlackRoom(channelid=channel['id'], bot=self) for channel in channels]

    def prefix_groupchat_reply(self, message, identifier):
        message.body = '@{0}: {1}'.format(identifier.nick, message.body)

    def remove_angle_brackets_from_uris(self, match_object):
        if "://" in match_object.group():
            return match_object.group().strip("<>")
        return match_object.group()
示例#2
0
class SlackBackend(ErrBot):
    def __init__(self, config):
        super().__init__(config)
        identity = config.BOT_IDENTITY
        self.token = identity.get('token', None)
        if not self.token:
            log.fatal(
                'You need to set your token (found under "Bot Integration" on Slack) in '
                'the BOT_IDENTITY setting in your configuration. Without this token I '
                'cannot connect to Slack.')
            sys.exit(1)
        self.sc = None  # Will be initialized in serve_once
        self.md = imtext()

    def api_call(self, method, data=None, raise_errors=True):
        """
        Make an API call to the Slack API and return response data.

        This is a thin wrapper around `SlackClient.server.api_call`.

        :param method:
            The API method to invoke (see https://api.slack.com/methods/).
        :param raise_errors:
            Whether to raise :class:`~SlackAPIResponseError` if the API
            returns an error
        :param data:
            A dictionary with data to pass along in the API request.
        :returns:
            The JSON-decoded API response
        :raises:
            :class:`~SlackAPIResponseError` if raise_errors is True and the
            API responds with `{"ok": false}`
        """
        if data is None:
            data = {}
        response = json.loads(
            self.sc.server.api_call(method, **data).decode('utf-8'))
        if raise_errors and not response['ok']:
            raise SlackAPIResponseError("Slack API call to %s failed: %s" %
                                        (method, response['error']),
                                        error=response['error'])
        return response

    def serve_once(self):
        self.sc = SlackClient(self.token)
        log.info("Verifying authentication token")
        self.auth = self.api_call("auth.test", raise_errors=False)
        if not self.auth['ok']:
            raise SlackAPIResponseError(
                error="Couldn't authenticate with Slack. Server said: %s" %
                self.auth['error'])
        log.debug("Token accepted")
        self.bot_identifier = SlackIdentifier(self.sc, self.auth["user_id"])

        log.info("Connecting to Slack real-time-messaging API")
        if self.sc.rtm_connect():
            log.info("Connected")
            self.reset_reconnection_count()
            try:
                while True:
                    for message in self.sc.rtm_read():
                        self._dispatch_slack_message(message)
                    time.sleep(1)
            except KeyboardInterrupt:
                log.info("Interrupt received, shutting down..")
                return True
            except:
                log.exception("Error reading from RTM stream:")
            finally:
                log.debug("Triggering disconnect callback")
                self.disconnect_callback()
        else:
            raise Exception('Connection failed, invalid token ?')

    def _dispatch_slack_message(self, message):
        """
        Process an incoming message from slack.

        """
        if 'type' not in message:
            log.debug("Ignoring non-event message: %s" % message)
            return

        event_type = message['type']

        event_handlers = {
            'hello': self._hello_event_handler,
            'presence_change': self._presence_change_event_handler,
            'team_join': self._team_join_event_handler,
            'message': self._message_event_handler,
        }

        event_handler = event_handlers.get(event_type)

        if event_handler is None:
            log.debug(
                "No event handler available for %s, ignoring this event" %
                event_type)
            return
        try:
            log.debug("Processing slack event: %s" % message)
            event_handler(message)
        except Exception:
            log.exception("%s event handler raised an exception" % event_type)

    def _hello_event_handler(self, event):
        """Event handler for the 'hello' event"""
        self.connect_callback()
        self.callback_presence(
            Presence(identifier=self.bot_identifier, status=ONLINE))

    def _presence_change_event_handler(self, event):
        """Event handler for the 'presence_change' event"""

        idd = SlackIdentifier(self.sc, event['user'])
        presence = event['presence']
        # According to https://api.slack.com/docs/presence, presence can
        # only be one of 'active' and 'away'
        if presence == 'active':
            status = ONLINE
        elif presence == 'away':
            status = AWAY
        else:
            log.error(
                "It appears the Slack API changed, I received an unknown presence type %s"
                % presence)
            status = ONLINE
        self.callback_presence(Presence(identifier=idd, status=status))

    def _team_join_event_handler(self, event):
        self.sc.parse_user_data((event['user'], ))

    def _message_event_handler(self, event):
        """Event handler for the 'message' event"""
        channel = event['channel']
        if channel.startswith('C'):
            log.debug("Handling message from a public channel")
            message_type = 'groupchat'
        elif channel.startswith('G'):
            log.debug("Handling message from a private group")
            message_type = 'groupchat'
        elif channel.startswith('D'):
            log.debug("Handling message from a user")
            message_type = 'chat'
        else:
            log.warning("Unknown message type! Unable to handle")
            return
        subtype = event.get('subtype', None)

        if subtype == "message_deleted":
            log.debug("Message of type message_deleted, ignoring this event")
            return
        if subtype == "message_changed" and 'attachments' in event['message']:
            # If you paste a link into Slack, it does a call-out to grab details
            # from it so it can display this in the chatroom. These show up as
            # message_changed events with an 'attachments' key in the embedded
            # message. We should completely ignore these events otherwise we
            # could end up processing bot commands twice (user issues a command
            # containing a link, it gets processed, then Slack triggers the
            # message_changed event and we end up processing it again as a new
            # message. This is not what we want).
            log.debug(
                "Ignoring message_changed event with attachments, likely caused "
                "by Slack auto-expanding a link")
            return

        if 'message' in event:
            text = event['message']['text']
            user = event['message'].get('user', event.get('bot_id'))
        else:
            text = event['text']
            user = event.get('user', event.get('bot_id'))

        text = re.sub("<[^>]*>", self.remove_angle_brackets_from_uris, text)

        log.debug("Saw an event: %s" % pprint.pformat(event))

        msg = Message(text,
                      type_=message_type,
                      extras={'attachments': event.get('attachments')})

        if message_type == 'chat':
            msg.frm = SlackIdentifier(self.sc, user, event['channel'])
            msg.to = SlackIdentifier(
                self.sc, self.username_to_userid(self.sc.server.username),
                event['channel'])
        else:
            msg.frm = SlackMUCOccupant(self.sc, user, event['channel'])
            msg.to = SlackMUCOccupant(
                self.sc, self.username_to_userid(self.sc.server.username),
                event['channel'])

        self.callback_message(msg)

    def userid_to_username(self, id_):
        """Convert a Slack user ID to their user name"""
        user = [user for user in self.sc.server.users if user.id == id_]
        if not user:
            raise UserDoesNotExistError("Cannot find user with ID %s" % id_)
        return user[0].name

    def username_to_userid(self, name):
        """Convert a Slack user name to their user ID"""
        user = [user for user in self.sc.server.users if user.name == name]
        if not user:
            raise UserDoesNotExistError("Cannot find user %s" % name)
        return user[0].id

    def channelid_to_channelname(self, id_):
        """Convert a Slack channel ID to its channel name"""
        channel = [
            channel for channel in self.sc.server.channels if channel.id == id_
        ]
        if not channel:
            raise RoomDoesNotExistError("No channel with ID %s exists" % id_)
        return channel[0].name

    def channelname_to_channelid(self, name):
        """Convert a Slack channel name to its channel ID"""
        if name.startswith('#'):
            name = name[1:]
        channel = [
            channel for channel in self.sc.server.channels
            if channel.name == name
        ]
        if not channel:
            raise RoomDoesNotExistError("No channel named %s exists" % name)
        return channel[0].id

    def channels(self, exclude_archived=True, joined_only=False):
        """
        Get all channels and groups and return information about them.

        :param exclude_archived:
            Exclude archived channels/groups
        :param joined_only:
            Filter out channels the bot hasn't joined
        :returns:
            A list of channel (https://api.slack.com/types/channel)
            and group (https://api.slack.com/types/group) types.

        See also:
          * https://api.slack.com/methods/channels.list
          * https://api.slack.com/methods/groups.list
        """
        response = self.api_call('channels.list',
                                 data={'exclude_archived': exclude_archived})
        channels = [
            channel for channel in response['channels']
            if channel['is_member'] or not joined_only
        ]

        response = self.api_call('groups.list',
                                 data={'exclude_archived': exclude_archived})
        # No need to filter for 'is_member' in this next call (it doesn't
        # (even exist) because leaving a group means you have to get invited
        # back again by somebody else.
        groups = [group for group in response['groups']]

        return channels + groups

    @lru_cache(50)
    def get_im_channel(self, id_):
        """Open a direct message channel to a user"""
        response = self.api_call('im.open', data={'user': id_})
        return response['channel']['id']

    def send_message(self, mess):
        super().send_message(mess)
        to_humanreadable = "<unknown>"
        try:
            if mess.type == 'groupchat':
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
            else:
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
                if to_channel_id.startswith('C'):
                    log.debug(
                        "This is a divert to private message, sending it directly to the user."
                    )
                    to_channel_id = self.get_im_channel(
                        self.username_to_userid(to_humanreadable))
            log.debug('Sending %s message to %s (%s)' %
                      (mess.type, to_humanreadable, to_channel_id))
            body = self.md.convert(mess.body)
            log.debug('Message size: %d' % len(body))

            limit = min(self.bot_config.MESSAGE_SIZE_LIMIT,
                        SLACK_MESSAGE_LIMIT)
            parts = self.prepare_message_body(body, limit)

            for part in parts:
                self.sc.rtm_send_message(to_channel_id, part)
        except Exception:
            log.exception(
                "An exception occurred while trying to send the following message "
                "to %s: %s" % (to_humanreadable, mess.body))

    @staticmethod
    def prepare_message_body(body, size_limit):
        """
        Returns the parts of a message chunked and ready for sending.

        This is a staticmethod for easier testing.

        Args:
            body (str)
            size_limit (int): chunk the body into sizes capped at this maximum

        Returns:
            [str]

        """
        fixed_format = body.startswith('```')  # hack to fix the formatting
        parts = list(split_string_after(body, size_limit))

        if len(parts) == 1:
            # If we've got an open fixed block, close it out
            if parts[0].count('```') % 2 != 0:
                parts[0] += '\n```\n'
        else:
            for i, part in enumerate(parts):
                starts_with_code = part.startswith('```')

                # If we're continuing a fixed block from the last part
                if fixed_format and not starts_with_code:
                    parts[i] = '```\n' + part

                # If we've got an open fixed block, close it out
                if part.count('```') % 2 != 0:
                    parts[i] += '\n```\n'

        return parts

    def build_identifier(self, txtrep):
        """ #channelname/username
            or @username
        """
        log.debug("building an identifier from %s" % txtrep)
        txtrep = txtrep.strip()
        channelname = None

        if txtrep[0] == '@':
            username = txtrep[1:]
        elif txtrep[0] == '#':
            plainrep = txtrep[1:]
            if '/' not in txtrep:
                raise Exception(
                    "Unparseable slack identifier, " +
                    "should be #channelname/username or @username : '******'" %
                    txtrep)
            channelname, username = plainrep.split('/')
        else:
            raise Exception(
                "Unparseable slack identifier, " +
                "should be #channelname/username or @username : '******'" % txtrep)

        userid = self.username_to_userid(username)
        if channelname:
            channelid = self.channelname_to_channelid(channelname)
            return SlackMUCOccupant(self.sc, userid, channelid)

        return SlackIdentifier(self.sc, userid, self.get_im_channel(userid))

    def build_reply(self, mess, text=None, private=False):
        msg_type = mess.type
        response = self.build_message(text)

        response.frm = self.bot_identifier
        response.to = mess.frm
        response.type = 'chat' if private else msg_type

        return response

    def shutdown(self):
        super().shutdown()

    @deprecated
    def join_room(self, room, username=None, password=None):
        return self.query_room(room)

    @property
    def mode(self):
        return 'slack'

    def query_room(self, room):
        """ Room can either be a name or a channelid """
        if room.startswith('C') or room.startswith('G'):
            return SlackRoom(channelid=room, bot=self)

        m = SLACK_CLIENT_CHANNEL_HYPERLINK.match(room)
        if m is not None:
            return SlackRoom(channelid=m.groupdict()['id'], bot=self)

        return SlackRoom(name=room, bot=self)

    def rooms(self):
        """
        Return a list of rooms the bot is currently in.

        :returns:
            A list of :class:`~SlackRoom` instances.
        """
        channels = self.channels(joined_only=True, exclude_archived=True)
        return [
            SlackRoom(channelid=channel['id'], bot=self)
            for channel in channels
        ]

    def prefix_groupchat_reply(self, message, identifier):
        message.body = '@{0}: {1}'.format(identifier.nick, message.body)

    def remove_angle_brackets_from_uris(self, match_object):
        if "://" in match_object.group():
            return match_object.group().strip("<>")
        return match_object.group()
示例#3
0
文件: slack.py 项目: Kelur/errbot
class SlackBackend(ErrBot):
    def __init__(self, config):
        super().__init__(config)
        identity = config.BOT_IDENTITY
        self.token = identity.get("token", None)
        if not self.token:
            log.fatal(
                'You need to set your token (found under "Bot Integration" on Slack) in '
                "the BOT_IDENTITY setting in your configuration. Without this token I "
                "cannot connect to Slack."
            )
            sys.exit(1)
        self.sc = None  # Will be initialized in serve_once
        self.md = imtext()

    def api_call(self, method, data=None, raise_errors=True):
        """
        Make an API call to the Slack API and return response data.

        This is a thin wrapper around `SlackClient.server.api_call`.

        :param method:
            The API method to invoke (see https://api.slack.com/methods/).
        :param raise_errors:
            Whether to raise :class:`~SlackAPIResponseError` if the API
            returns an error
        :param data:
            A dictionary with data to pass along in the API request.
        :returns:
            The JSON-decoded API response
        :raises:
            :class:`~SlackAPIResponseError` if raise_errors is True and the
            API responds with `{"ok": false}`
        """
        if data is None:
            data = {}
        response = json.loads(self.sc.server.api_call(method, **data).decode("utf-8"))
        if raise_errors and not response["ok"]:
            raise SlackAPIResponseError(
                "Slack API call to %s failed: %s" % (method, response["error"]), error=response["error"]
            )
        return response

    def serve_once(self):
        self.sc = SlackClient(self.token)
        log.info("Verifying authentication token")
        self.auth = self.api_call("auth.test", raise_errors=False)
        if not self.auth["ok"]:
            raise SlackAPIResponseError(error="Couldn't authenticate with Slack. Server said: %s" % self.auth["error"])
        log.debug("Token accepted")
        self.bot_identifier = SlackIdentifier(self.sc, self.auth["user_id"])

        log.info("Connecting to Slack real-time-messaging API")
        if self.sc.rtm_connect():
            log.info("Connected")
            self.reset_reconnection_count()
            try:
                while True:
                    for message in self.sc.rtm_read():
                        self._dispatch_slack_message(message)
                    time.sleep(1)
            except KeyboardInterrupt:
                log.info("Interrupt received, shutting down..")
                return True
            except:
                log.exception("Error reading from RTM stream:")
            finally:
                log.debug("Triggering disconnect callback")
                self.disconnect_callback()
        else:
            raise Exception("Connection failed, invalid token ?")

    def _dispatch_slack_message(self, message):
        """
        Process an incoming message from slack.

        """
        if "type" not in message:
            log.debug("Ignoring non-event message: %s" % message)
            return

        event_type = message["type"]

        event_handlers = {
            "hello": self._hello_event_handler,
            "presence_change": self._presence_change_event_handler,
            "team_join": self._team_join_event_handler,
            "message": self._message_event_handler,
        }

        event_handler = event_handlers.get(event_type)

        if event_handler is None:
            log.debug("No event handler available for %s, ignoring this event" % event_type)
            return
        try:
            log.debug("Processing slack event: %s" % message)
            event_handler(message)
        except Exception:
            log.exception("%s event handler raised an exception" % event_type)

    def _hello_event_handler(self, event):
        """Event handler for the 'hello' event"""
        self.connect_callback()
        self.callback_presence(Presence(identifier=self.bot_identifier, status=ONLINE))

    def _presence_change_event_handler(self, event):
        """Event handler for the 'presence_change' event"""

        idd = SlackIdentifier(self.sc, event["user"])
        presence = event["presence"]
        # According to https://api.slack.com/docs/presence, presence can
        # only be one of 'active' and 'away'
        if presence == "active":
            status = ONLINE
        elif presence == "away":
            status = AWAY
        else:
            log.error("It appears the Slack API changed, I received an unknown presence type %s" % presence)
            status = ONLINE
        self.callback_presence(Presence(identifier=idd, status=status))

    def _team_join_event_handler(self, event):
        self.sc.parse_user_data((event["user"],))

    def _message_event_handler(self, event):
        """Event handler for the 'message' event"""
        channel = event["channel"]
        if channel.startswith("C"):
            log.debug("Handling message from a public channel")
            message_type = "groupchat"
        elif channel.startswith("G"):
            log.debug("Handling message from a private group")
            message_type = "groupchat"
        elif channel.startswith("D"):
            log.debug("Handling message from a user")
            message_type = "chat"
        else:
            log.warning("Unknown message type! Unable to handle")
            return
        subtype = event.get("subtype", None)

        if subtype == "message_deleted":
            log.debug("Message of type message_deleted, ignoring this event")
            return
        if subtype == "message_changed" and "attachments" in event["message"]:
            # If you paste a link into Slack, it does a call-out to grab details
            # from it so it can display this in the chatroom. These show up as
            # message_changed events with an 'attachments' key in the embedded
            # message. We should completely ignore these events otherwise we
            # could end up processing bot commands twice (user issues a command
            # containing a link, it gets processed, then Slack triggers the
            # message_changed event and we end up processing it again as a new
            # message. This is not what we want).
            log.debug(
                "Ignoring message_changed event with attachments, likely caused " "by Slack auto-expanding a link"
            )
            return

        if "message" in event:
            text = event["message"]["text"]
            user = event["message"].get("user", event.get("bot_id"))
        else:
            text = event["text"]
            user = event.get("user", event.get("bot_id"))

        text = re.sub("<[^>]*>", self.remove_angle_brackets_from_uris, text)

        log.debug("Saw an event: %s" % pprint.pformat(event))

        msg = Message(text, type_=message_type, extras={"attachments": event.get("attachments")})

        if message_type == "chat":
            msg.frm = SlackIdentifier(self.sc, user, event["channel"])
            msg.to = SlackIdentifier(self.sc, self.username_to_userid(self.sc.server.username), event["channel"])
        else:
            msg.frm = SlackMUCOccupant(self.sc, user, event["channel"])
            msg.to = SlackMUCOccupant(self.sc, self.username_to_userid(self.sc.server.username), event["channel"])

        self.callback_message(msg)

    def userid_to_username(self, id_):
        """Convert a Slack user ID to their user name"""
        user = [user for user in self.sc.server.users if user.id == id_]
        if not user:
            raise UserDoesNotExistError("Cannot find user with ID %s" % id_)
        return user[0].name

    def username_to_userid(self, name):
        """Convert a Slack user name to their user ID"""
        user = [user for user in self.sc.server.users if user.name == name]
        if not user:
            raise UserDoesNotExistError("Cannot find user %s" % name)
        return user[0].id

    def channelid_to_channelname(self, id_):
        """Convert a Slack channel ID to its channel name"""
        channel = [channel for channel in self.sc.server.channels if channel.id == id_]
        if not channel:
            raise RoomDoesNotExistError("No channel with ID %s exists" % id_)
        return channel[0].name

    def channelname_to_channelid(self, name):
        """Convert a Slack channel name to its channel ID"""
        if name.startswith("#"):
            name = name[1:]
        channel = [channel for channel in self.sc.server.channels if channel.name == name]
        if not channel:
            raise RoomDoesNotExistError("No channel named %s exists" % name)
        return channel[0].id

    def channels(self, exclude_archived=True, joined_only=False):
        """
        Get all channels and groups and return information about them.

        :param exclude_archived:
            Exclude archived channels/groups
        :param joined_only:
            Filter out channels the bot hasn't joined
        :returns:
            A list of channel (https://api.slack.com/types/channel)
            and group (https://api.slack.com/types/group) types.

        See also:
          * https://api.slack.com/methods/channels.list
          * https://api.slack.com/methods/groups.list
        """
        response = self.api_call("channels.list", data={"exclude_archived": exclude_archived})
        channels = [channel for channel in response["channels"] if channel["is_member"] or not joined_only]

        response = self.api_call("groups.list", data={"exclude_archived": exclude_archived})
        # No need to filter for 'is_member' in this next call (it doesn't
        # (even exist) because leaving a group means you have to get invited
        # back again by somebody else.
        groups = [group for group in response["groups"]]

        return channels + groups

    @lru_cache(50)
    def get_im_channel(self, id_):
        """Open a direct message channel to a user"""
        response = self.api_call("im.open", data={"user": id_})
        return response["channel"]["id"]

    def send_message(self, mess):
        super().send_message(mess)
        to_humanreadable = "<unknown>"
        try:
            if mess.type == "groupchat":
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
            else:
                to_humanreadable = mess.to.username
                to_channel_id = mess.to.channelid
                if to_channel_id.startswith("C"):
                    log.debug("This is a divert to private message, sending it directly to the user.")
                    to_channel_id = self.get_im_channel(self.username_to_userid(to_humanreadable))
            log.debug("Sending %s message to %s (%s)" % (mess.type, to_humanreadable, to_channel_id))
            body = self.md.convert(mess.body)
            log.debug("Message size: %d" % len(body))

            limit = min(self.bot_config.MESSAGE_SIZE_LIMIT, SLACK_MESSAGE_LIMIT)
            parts = self.prepare_message_body(body, limit)

            for part in parts:
                self.sc.rtm_send_message(to_channel_id, part)
        except Exception:
            log.exception(
                "An exception occurred while trying to send the following message "
                "to %s: %s" % (to_humanreadable, mess.body)
            )

    def change_presence(self, status: str = ONLINE, message: str = "") -> None:
        self.api_call("users.setPresence", data={"presence": "auto" if status == ONLINE else "away"})

    @staticmethod
    def prepare_message_body(body, size_limit):
        """
        Returns the parts of a message chunked and ready for sending.

        This is a staticmethod for easier testing.

        Args:
            body (str)
            size_limit (int): chunk the body into sizes capped at this maximum

        Returns:
            [str]

        """
        fixed_format = body.startswith("```")  # hack to fix the formatting
        parts = list(split_string_after(body, size_limit))

        if len(parts) == 1:
            # If we've got an open fixed block, close it out
            if parts[0].count("```") % 2 != 0:
                parts[0] += "\n```\n"
        else:
            for i, part in enumerate(parts):
                starts_with_code = part.startswith("```")

                # If we're continuing a fixed block from the last part
                if fixed_format and not starts_with_code:
                    parts[i] = "```\n" + part

                # If we've got an open fixed block, close it out
                if part.count("```") % 2 != 0:
                    parts[i] += "\n```\n"

        return parts

    @staticmethod
    def extract_identifiers_from_string(text):
        """
        Parse a string for Slack user/channel IDs.

        Supports strings with the following formats::

            <#C12345>
            <@U12345>
            @user
            #channel/user
            #channel

        Returns the tuple (username, userid, channelname, channelid).
        Some elements may come back as None.
        """
        exception_message = (
            "Unparseable slack identifier, should be of the format `<#C12345>`, `<@U12345>`, "
            "`@user`, `#channel/user` or `#channel`. (Got `%s`)"
        )
        text = text.strip()

        if text == "":
            raise ValueError(exception_message % "")

        channelname = None
        username = None
        channelid = None
        userid = None

        if text[0] == "<" and text[-1] == ">":
            exception_message = "Unparseable slack ID, should start with U, B, C, G or D " "(got `%s`)"
            text = text[2:-1]
            if text == "":
                raise ValueError(exception_message % "")
            if text[0] in ("U", "B"):
                userid = text
            elif text[0] in ("C", "G", "D"):
                channelid = text
            else:
                raise ValueError(exception_message % text)
        elif text[0] == "@":
            username = text[1:]
        elif text[0] == "#":
            plainrep = text[1:]
            if "/" in text:
                channelname, username = plainrep.split("/", 1)
            else:
                channelname = plainrep
        else:
            raise ValueError(exception_message % text)

        return username, userid, channelname, channelid

    def build_identifier(self, txtrep):
        """
        Build a :class:`SlackIdentifier` from the given string txtrep.

        Supports strings with the formats accepted by
        :func:`~extract_identifiers_from_string`.
        """
        log.debug("building an identifier from %s" % txtrep)
        username, userid, channelname, channelid = self.extract_identifiers_from_string(txtrep)

        if userid is not None:
            return SlackIdentifier(self.sc, userid, self.get_im_channel(userid))
        if channelid is not None:
            return SlackIdentifier(self.sc, None, channelid)
        if username is not None:
            userid = self.username_to_userid(username)
            return SlackIdentifier(self.sc, userid, self.get_im_channel(userid))
        if channelname is not None:
            channelid = self.channelname_to_channelid(channelname)
            return SlackMUCOccupant(self.sc, userid, channelid)

        raise Exception(
            "You found a bug. I expected at least one of userid, channelid, username or channelname "
            "to be resolved but none of them were. This shouldn't happen so, please file a bug."
        )

    def build_reply(self, mess, text=None, private=False):
        msg_type = mess.type
        response = self.build_message(text)

        response.frm = self.bot_identifier
        response.to = mess.frm
        response.type = "chat" if private else msg_type

        return response

    def shutdown(self):
        super().shutdown()

    @deprecated
    def join_room(self, room, username=None, password=None):
        return self.query_room(room)

    @property
    def mode(self):
        return "slack"

    def query_room(self, room):
        """ Room can either be a name or a channelid """
        if room.startswith("C") or room.startswith("G"):
            return SlackRoom(channelid=room, bot=self)

        m = SLACK_CLIENT_CHANNEL_HYPERLINK.match(room)
        if m is not None:
            return SlackRoom(channelid=m.groupdict()["id"], bot=self)

        return SlackRoom(name=room, bot=self)

    def rooms(self):
        """
        Return a list of rooms the bot is currently in.

        :returns:
            A list of :class:`~SlackRoom` instances.
        """
        channels = self.channels(joined_only=True, exclude_archived=True)
        return [SlackRoom(channelid=channel["id"], bot=self) for channel in channels]

    def prefix_groupchat_reply(self, message, identifier):
        message.body = "@{0}: {1}".format(identifier.nick, message.body)

    def remove_angle_brackets_from_uris(self, match_object):
        if "://" in match_object.group():
            return match_object.group().strip("<>")
        return match_object.group()