Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

My Slack Events backend version #2

Open
duhow opened this issue Jan 20, 2021 · 7 comments
Open

My Slack Events backend version #2

duhow opened this issue Jan 20, 2021 · 7 comments

Comments

@duhow
Copy link
Collaborator

duhow commented Jan 20, 2021

Compared against default Slack Backend from Errbot 6.1.6: https://github.com/errbotio/errbot/blob/6.1.6/errbot/backends/slack.py

--- a/errbot/backends/slack.py
+++ b/errbot/backends/slack.py
@@ -4,6 +4,7 @@
 import logging
 import re
 import sys
+import time
 import pprint
 from functools import lru_cache
 from typing import BinaryIO
@@ -17,16 +18,19 @@
 from errbot.core import ErrBot
 from errbot.utils import split_string_after
 from errbot.rendering.ansiext import AnsiExtension, enable_format, IMTEXT_CHRS
+from errbot.core_plugins import flask_app
+from errbot.core_plugins.wsview import WebView
 
 log = logging.getLogger(__name__)
 
 try:
-    from slackclient import SlackClient
+    from slack_sdk import WebClient as SlackClient
+    from slack_sdk.errors import SlackApiError
 except ImportError:
     log.exception("Could not start the Slack back-end")
     log.fatal(
-        "You need to install the slackclient support in order to use the Slack backend.\n"
-        "You can do `pip install errbot[slack]` to install it"
+        "You need to install the slack_sdk library in order to use the Slack backend.\n"
+        "You can do `pip install slack_sdk` to install it"
     )
     sys.exit(1)
 
@@ -98,7 +102,7 @@
     This class describes a person on Slack's network.
     """
 
-    def __init__(self, sc, userid=None, channelid=None):
+    def __init__(self, userid, channelid=None, bot=None):
         if userid is not None and userid[0] not in ('U', 'B', 'W'):
             raise Exception(f'This is not a Slack user or bot id: {userid} (should start with U, B or W)')
 
@@ -107,20 +111,37 @@
 
         self._userid = userid
         self._channelid = channelid
-        self._sc = sc
+        self._bot = bot
+        self._sc = getattr(bot, 'sc', None)
+        self._info = None
 
     @property
     def userid(self):
         return self._userid
 
     @property
+    def info(self):
+        """ Get and store information from user or channel """
+        if self._info is None:
+            try:
+                user = self._bot.get_users_info(self._userid)
+                if user is None:
+                    raise SlackApiError
+                self._info = user
+            except SlackApiError as e:
+                log.error(f'Cannot find user with ID {self._userid} - {e.response["error"]}')
+                return f'<{self._userid}>'
+        return self._info
+
+    @property
     def username(self):
         """Convert a Slack user ID to their user name"""
-        user = self._sc.server.users.find(self._userid)
-        if user is None:
-            log.error('Cannot find user with ID %s', self._userid)
-            return f'<{self._userid}>'
-        return user.name
+        # https://api.slack.com/changelog/2017-09-the-one-about-usernames
+        return (
+            self.info.get('profile', {}).get('display_name_normalized') or
+            self.info.get('profile', {}).get('real_name_normalized') or
+            f'<{self._userid}>'
+        )
 
     @property
     def channelid(self):
@@ -132,10 +153,12 @@
         if self._channelid is None:
             return None
 
-        channel = self._sc.server.channels.find(self._channelid)
-        if channel is None:
+        channel = self._bot.get_conversations_info(self._channelid)
+        if not channel:
             raise RoomDoesNotExistError(f'No channel with ID {self._channelid} exists.')
-        return channel.name
+        if channel['is_im']:
+            return channel['user']
+        return channel['name']
 
     @property
     def domain(self):
@@ -150,25 +173,17 @@
     def aclattr(self):
         # Note: Don't use str(self) here because that will return
         # an incorrect format from SlackMUCOccupant.
-        return f'@{self.username}'
+        return f'@{self.userid}'
 
     @property
     def email(self):
         """Convert a Slack user ID to their user email"""
-        user = self._sc.server.users.find(self._userid)
-        if user is None:
-            log.error("Cannot find user with ID %s" % self._userid)
-            return "<%s>" % self._userid
-        return user.email
+        return self.info.get('profile', {}).get('email', f'<{self._userid}>')
 
     @property
     def fullname(self):
         """Convert a Slack user ID to their user name"""
-        user = self._sc.server.users.find(self._userid)
-        if user is None:
-            log.error('Cannot find user with ID %s', self._userid)
-            return f'<{self._userid}>'
-        return user.real_name
+        return self.info.get('real_name', f'<{self._userid}>')
 
     def __unicode__(self):
         return f'@{self.username}'
@@ -178,7 +193,7 @@
 
     def __eq__(self, other):
         if not isinstance(other, SlackPerson):
-            log.warning('tried to compare a SlackPerson with a %s', type(other))
+            log.warning(f'tried to compare a SlackPerson with a {type(other)}')
             return False
         return other.userid == self.userid
 
@@ -197,9 +212,13 @@
     This class represents a person inside a MUC.
     """
 
-    def __init__(self, sc, userid, channelid, bot):
-        super().__init__(sc, userid, channelid)
-        self._room = SlackRoom(channelid=channelid, bot=bot)
+    def __init__(self, userid, channelid, bot):
+        if isinstance(channelid, SlackRoom):
+            super().__init__(userid, channelid.id, bot)
+            self._room = channelid
+        else:
+            super().__init__(userid, channelid, bot)
+            self._room = SlackRoom(channelid=channelid, bot=bot)
 
     @property
     def room(self):
@@ -213,7 +232,7 @@
 
     def __eq__(self, other):
         if not isinstance(other, RoomOccupant):
-            log.warning('tried to compare a SlackRoomOccupant with a SlackPerson %s vs %s', self, other)
+            log.warning(f'tried to compare a SlackRoomOccupant with a SlackPerson {self} vs {other}')
             return False
         return other.room.id == self.room.id and other.userid == self.userid
 
@@ -223,10 +242,10 @@
     This class describes a bot on Slack's network.
     """
 
-    def __init__(self, sc, bot_id, bot_username):
+    def __init__(self, bot_id, bot_username, bot):
         self._bot_id = bot_id
         self._bot_username = bot_username
-        super().__init__(sc=sc, userid=bot_id)
+        super().__init__(bot_id, bot=bot)
 
     @property
     def username(self):
@@ -251,9 +270,12 @@
     This class represents a bot inside a MUC.
     """
 
-    def __init__(self, sc, bot_id, bot_username, channelid, bot):
-        super().__init__(sc, bot_id, bot_username)
-        self._room = SlackRoom(channelid=channelid, bot=bot)
+    def __init__(self, bot_id, bot_username, channelid, bot):
+        super().__init__(bot_id, bot_username)
+        if isinstance(channelid, SlackRoom):
+            self._room = channelid
+        else:
+            self._room = SlackRoom(channelid=channelid, bot=bot)
 
     @property
     def room(self):
@@ -267,22 +289,22 @@
 
     def __eq__(self, other):
         if not isinstance(other, RoomOccupant):
-            log.warning('tried to compare a SlackRoomBotOccupant with a SlackPerson %s vs %s', self, other)
+            log.warning(f'tried to compare a SlackRoomBotOccupant with a SlackPerson {self} vs {other}')
             return False
         return other.room.id == self.room.id and other.userid == self.userid
 
 
-class SlackBackend(ErrBot):
+class SlackEventsBackend(ErrBot):
 
     room_types = 'public_channel,private_channel'
 
     @staticmethod
     def _unpickle_identifier(identifier_str):
-        return SlackBackend.__build_identifier(identifier_str)
+        return SlackEventsBackend.__build_identifier(identifier_str)
 
     @staticmethod
     def _pickle_identifier(identifier):
-        return SlackBackend._unpickle_identifier, (str(identifier),)
+        return SlackEventsBackend._unpickle_identifier, (str(identifier),)
 
     def _register_identifiers_pickling(self):
         """
@@ -292,15 +314,17 @@
         But for the unpickling to work we need to use bot.build_identifier, hence the bot parameter here.
         But then we also need bot for the unpickling so we save it here at module level.
         """
-        SlackBackend.__build_identifier = self.build_identifier
+        SlackEventsBackend.__build_identifier = self.build_identifier
         for cls in (SlackPerson, SlackRoomOccupant, SlackRoom):
-            copyreg.pickle(cls, SlackBackend._pickle_identifier, SlackBackend._unpickle_identifier)
+            copyreg.pickle(cls, SlackEventsBackend._pickle_identifier, SlackEventsBackend._unpickle_identifier)
 
     def __init__(self, config):
         super().__init__(config)
         identity = config.BOT_IDENTITY
+        self.slack_event_webhook = '/slack/events'
         self.token = identity.get('token', None)
         self.proxies = identity.get('proxies', None)
+        self.signing = identity.get('signing_secret', None)
         if not self.token:
             log.fatal(
                 'You need to set your token (found under "Bot Integration" on Slack) in '
@@ -340,10 +364,8 @@
         """
         if data is None:
             data = {}
-        response = self.sc.api_call(method, **data)
-        if not isinstance(response, collections.Mapping):
-            # Compatibility with SlackClient < 1.0.0
-            response = json.loads(response.decode('utf-8'))
+        method = method.replace('.', '_')
+        response = getattr(self.sc, method)(**data)
 
         if raise_errors and not response['ok']:
             raise SlackAPIResponseError(f"Slack API call to {method} failed: {response['error']}",
@@ -364,66 +386,103 @@
 
         converted_prefixes = []
         for prefix in bot_prefixes:
-            try:
-                converted_prefixes.append(f'<@{self.username_to_userid(prefix)}>')
-            except Exception as e:
-                log.error('Failed to look up Slack userid for alternate prefix "%s": %s', prefix, e)
+            converted_prefixes.append(f'<@{prefix}>')
 
         self.bot_alt_prefixes = tuple(x.lower() for x in self.bot_config.BOT_ALT_PREFIXES)
-        log.debug('Converted bot_alt_prefixes: %s', self.bot_config.BOT_ALT_PREFIXES)
+        log.debug(f'Converted bot_alt_prefixes: {self.bot_config.BOT_ALT_PREFIXES}')
 
-    def serve_once(self):
-        self.sc = SlackClient(self.token, proxies=self.proxies)
+    def serve_forever(self):
+        self.sc = SlackClient(self.token, proxy=self.proxies)
 
         log.info('Verifying authentication token')
-        self.auth = self.api_call("auth.test", raise_errors=False)
+        self.auth = self.api_call("auth_test", raise_errors=False)
         if not self.auth['ok']:
             raise SlackAPIResponseError(error=f"Couldn't authenticate with Slack. Server said: {self.auth['error']}")
         log.debug("Token accepted")
-        self.bot_identifier = SlackPerson(self.sc, self.auth["user_id"])
+        self.bot_identifier = SlackBot(self.auth["user_id"], self.auth["user"], self)
 
-        log.info("Connecting to Slack real-time-messaging API")
-        if self.sc.rtm_connect():
-            log.info("Connected")
-            # Block on reads instead of using the busy loop suggested in slackclient docs
-            # https://github.com/slackapi/python-slackclient/issues/46#issuecomment-165674808
-            self.sc.server.websocket.sock.setblocking(True)
-            self.reset_reconnection_count()
+        # Setup webhook to Errbot flask
+        callable_view = WebView.as_view(
+            self._dispatch_slack_message.__name__ + '_POST',
+            self._dispatch_slack_message,
+            None,
+            True
+        )
+        flask_app.add_url_rule(
+            self.slack_event_webhook,
+            view_func=callable_view,
+            methods=('POST', ),
+            strict_slashes=False
+        )
 
-            # Inject bot identity to alternative prefixes
-            self.update_alternate_prefixes()
+        log.info(f"Added webhook {self.slack_event_webhook} to Slack Events")
 
-            try:
-                while True:
-                    for message in self.sc.rtm_read():
-                        self._dispatch_slack_message(message)
-            except KeyboardInterrupt:
-                log.info("Interrupt received, shutting down..")
-                return True
-            except Exception:
-                log.exception("Error reading from RTM stream:")
-            finally:
-                log.debug("Triggering disconnect callback")
-                self.disconnect_callback()
-        else:
-            raise Exception('Connection failed, invalid token ?')
+        self.update_alternate_prefixes()
 
-    def _dispatch_slack_message(self, message):
+        log.info("Slack calls ready")
+        self.connect_callback()
+        self.running = True
+        self.callback_presence(Presence(identifier=self.bot_identifier, status=ONLINE))
+        try:
+            while self.running:
+                time.sleep(.5)
+        except KeyboardInterrupt:
+            log.info("Interrupt received, shutting down..")
+            return True
+        except Exception:
+            log.exception("Error exception logged while on hold")
+        finally:
+            log.debug("Triggering disconnect callback")
+            self.disconnect_callback()
+
+    def _dispatch_slack_message(self, request):
         """
         Process an incoming message from slack.
 
         """
+
+        if request.json:
+            message = request.json
+        elif request.form.get('payload', None):
+            try:
+                message = json.loads(request.form['payload'])
+                log.debug(message)
+            except ValueError:
+                log.warn("Request payload received is not JSON.")
+                return
+        else:
+            log.warn("Invalid request.")
+            return
+
+        if request.headers.get('X-Slack-Retry-Num', None):
+            log.warn(f'Received a Slack Retry message, rejecting. Reason: {request.headers["X-Slack-Retry-Reason"]}')
+            return
+
+        # Avoid replay attacks
+        timestamp = int(request.headers.get('X-Slack-Request-Timestamp', 0))
+        time_request = abs(time.time() - timestamp)
+        if time_request > 300:
+            log.warn(f'Received a Slack Request older than 300 seconds, ignoring.')
+            return
+
         if 'type' not in message:
-            log.debug("Ignoring non-event message: %s.", message)
+            log.debug(f'Ignoring non-event message: {message}.')
             return
 
-        event_type = message['type']
+        event_type = message.get('event', message)['type']
 
         event_handlers = {
             'hello': self._hello_event_handler,
             'presence_change': self._presence_change_event_handler,
             'message': self._message_event_handler,
+            'view_closed': self._views_event_handler,
+            'view_submission': self._views_event_handler,
+            'interactive_message': self._interactive_message_event_handler,
             'member_joined_channel': self._member_joined_channel_event_handler,
+            'member_left_channel': self._member_left_channel_event_handler,
+            'url_verification': self._url_verification_event_handler,
+            'user_change': self._user_change_event_handler,
+            'app_mention': self._message_event_handler,
             'reaction_added': self._reaction_event_handler,
             'reaction_removed': self._reaction_event_handler
         }
@@ -431,14 +490,28 @@
         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)
+            log.debug(f'No event handler available for {event_type}, ignoring this event')
             return
         try:
-            log.debug('Processing slack event: %s', message)
-            event_handler(message)
+            log.debug(f'Processing slack event: {event_type}')
+            return event_handler(message)
         except Exception:
             log.exception(f'{event_type} event handler raised an exception')
 
+    def _user_change_event_handler(self, event):
+        """Event handler for the 'user_change' event"""
+        # event = event.get('event', event)
+        # user = event.get('user', None)
+
+        # nothing to handle at the moment
+        pass
+
+    def _url_verification_event_handler(self, message):
+        """Event handler for the 'url_verification' event"""
+        # If bot config contains verification_token and
+        # is the same as stored, otherwise return true
+        return message['challenge']
+
     def _hello_event_handler(self, event):
         """Event handler for the 'hello' event"""
         self.connect_callback()
@@ -447,7 +520,7 @@
     def _presence_change_event_handler(self, event):
         """Event handler for the 'presence_change' event"""
 
-        idd = SlackPerson(self.sc, event['user'])
+        idd = SlackPerson(event['user'], bot=self)
         presence = event['presence']
         # According to https://api.slack.com/docs/presence, presence can
         # only be one of 'active' and 'away'
@@ -460,17 +533,84 @@
             status = ONLINE
         self.callback_presence(Presence(identifier=idd, status=status))
 
+    def _views_event_handler(self, event):
+        """
+        Event handler for the 'views' event, for modals
+        """
+
+        data = {}
+        # Try to extract data
+        for key, vals in event['view']['state']['values'].items():
+            for skey in vals:
+                fkey = f'{key}_{skey}'
+                if 'selected_option' in vals[skey]:
+                    data[fkey] = vals[skey]['selected_option']['value']
+                elif 'selected_options' in vals[skey]:
+                    data[fkey] = [x.get('value') for x in vals[skey]['selected_options']]
+                else:
+                    data[fkey] = vals[skey].get('value', None)
+                log.debug(f'{fkey} = {data[fkey]}')
+
+        msg = Message(
+            frm=SlackPerson(event['user']['id'], bot=self),
+            to=self.bot_identifier,
+            extras={
+                'type': event['type'],
+                'state': event['view']['state']['values'],
+                'values': data,
+                'url': event.get('response_urls', []),
+                'trigger_id': event.get('trigger_id', None),
+                'callback_id': event.get(
+                    'callback_id', event['view'].get('callback_id', None)),
+                'slack_event': event
+            }
+        )
+
+        flow, _ = self.flow_executor.check_inflight_flow_triggered(msg.extras['callback_id'], msg.frm)
+        if flow:
+            log.debug("Reattach context from flow %s to the message", flow._root.name)
+            msg.ctx = flow.ctx
+
+        self.callback_message(msg)
+        log.debug(f'Data to be returned: {msg.body}')
+        return msg.body
+
+    def _interactive_message_event_handler(self, event):
+        """
+        Event handler for the 'interactive' event, used in attachments / buttons.
+        """
+        msg = Message(
+            frm=SlackPerson(event['user']['id'], event['channel']['id'], bot=self),
+            to=self.bot_identifier,
+            extras={
+                'actions': [{x['name']: x} for x in event['actions']],
+                'url': event['response_url'],
+                'trigger_id': event.get('trigger_id', None),
+                'callback_id': event.get('callback_id', None),
+                'slack_event': event
+            }
+        )
+
+        flow, _ = self.flow_executor.check_inflight_flow_triggered(msg.extras['callback_id'], msg.frm)
+        if flow:
+            log.debug("Reattach context from flow %s to the message", flow._root.name)
+            msg.ctx = flow.ctx
+
+        self.callback_message(msg)
+
     def _message_event_handler(self, event):
         """Event handler for the 'message' event"""
+        event = event.get('event', event)
         channel = event['channel']
         if channel[0] not in 'CGD':
-            log.warning("Unknown message type! Unable to handle %s", channel)
+            log.warning(f'Unknown message type! Unable to handle {channel}')
             return
 
         subtype = event.get('subtype', None)
 
-        if subtype in ("message_deleted", "channel_topic", "message_replied"):
-            log.debug("Message of type %s, ignoring this event", subtype)
+        if subtype in ("message_deleted", "channel_topic", "message_replied",
+                       "channel_join", "channel_leave"):
+            log.debug(f'Message of type {subtype}, ignoring this event')
             return
 
         if subtype == "message_changed" and 'attachments' in event['message']:
@@ -495,16 +635,21 @@
             text = event.get('text', '')
             user = event.get('user', event.get('bot_id'))
 
+        if (event['type'] == 'app_mention' and not event['channel'].startswith('G')):
+            log.debug('Ignoring app_mention event on non-private channel, message event will handle it.')
+            return
+
         text, mentioned = self.process_mentions(text)
 
         text = self.sanitize_uris(text)
 
         log.debug('Saw an event: %s', pprint.pformat(event))
-        log.debug('Escaped IDs event text: %s', text)
+        log.debug(f'Escaped IDs event text: {text}')
 
         msg = Message(
             text,
             extras={
+                'mentions': mentioned,
                 'attachments': event.get('attachments'),
                 'slack_event': event,
             },
@@ -513,30 +658,38 @@
         if channel.startswith('D'):
             if subtype == "bot_message":
                 msg.frm = SlackBot(
-                    self.sc,
                     bot_id=event.get('bot_id'),
-                    bot_username=event.get('username', '')
+                    bot_username=event.get('username', ''),
+                    bot=self
                 )
+                msg.to = SlackPerson(user, event['channel'], self)
             else:
-                msg.frm = SlackPerson(self.sc, user, event['channel'])
-            msg.to = SlackPerson(self.sc, self.username_to_userid(self.sc.server.username),
-                                 event['channel'])
+                if user == self.bot_identifier.userid:
+                    msg.frm = self.bot_identifier
+                    msg.to = self.bot_identifier
+                else:
+                    msg.frm = SlackPerson(user, event['channel'], self)
+                    msg.to = msg.frm
             channel_link_name = event['channel']
         else:
             if subtype == "bot_message":
                 msg.frm = SlackRoomBot(
-                    self.sc,
                     bot_id=event.get('bot_id'),
                     bot_username=event.get('username', ''),
                     channelid=event['channel'],
                     bot=self
                 )
+                msg.to = SlackRoom(channelid=event['channel'], bot=self)
             else:
-                msg.frm = SlackRoomOccupant(self.sc, user, event['channel'], bot=self)
-            msg.to = SlackRoom(channelid=event['channel'], bot=self)
-            channel_link_name = msg.to.name
+                if user == self.bot_identifier.userid:
+                    msg.frm = self.bot_identifier
+                    msg.to = self.bot_identifier
+                else:
+                    msg.to = SlackRoom(channelid=event['channel'], bot=self)
+                    msg.frm = SlackRoomOccupant(user, msg.to, bot=self)
+            channel_link_name = event['channel']
 
-        msg.extras['url'] = f'https://{self.sc.server.domain}.slack.com/archives/' \
+        msg.extras['url'] = f'{self.auth["url"]}archives/' \
                             f'{channel_link_name}/p{self._ts_for_message(msg).replace(".", "")}'
 
         self.callback_message(msg)
@@ -544,20 +697,41 @@
         if mentioned:
             self.callback_mention(msg, mentioned)
 
+    def _member_left_channel_event_handler(self, event):
+        """Event handler for the 'member_left_channel' event"""
+        return self._member_channel_event_handler(event, 'left')
+
     def _member_joined_channel_event_handler(self, event):
         """Event handler for the 'member_joined_channel' event"""
-        user = SlackPerson(self.sc, event['user'])
+        return self._member_channel_event_handler(event, 'joined')
+
+    def _member_channel_event_handler(self, event, action):
+        event = event.get('event', event)
+        user = SlackPerson(event['user'], bot=self)
+        log.info(f'User {user} has {action} channel {event["channel"]}')
         if user == self.bot_identifier:
-            self.callback_room_joined(SlackRoom(channelid=event['channel'], bot=self))
+            user = self.bot_identifier
+
+        occupant = SlackRoomOccupant(
+            userid=user.userid,
+            channelid=event['channel'],
+            bot=self
+        )
+
+        if action == 'left':
+            self.callback_room_left(occupant)
+        elif action == 'joined':
+            self.callback_room_joined(occupant)
 
     def _reaction_event_handler(self, event):
         """Event handler for the 'reaction_added'
            and 'reaction_removed' events"""
 
-        user = SlackPerson(self.sc, event['user'])
+        event = event.get('event', event)
+        user = SlackPerson(event['user'], bot=self)
         item_user = None
         if event['item_user']:
-            item_user = SlackPerson(self.sc, event['item_user'])
+            item_user = SlackPerson(event['item_user'], bot=self)
 
         action = REACTION_ADDED
         if event['type'] == 'reaction_removed':
@@ -571,37 +745,77 @@
                             reacted_to=event['item']
                             )
 
+        log.debug(f'{user.userid} reaction {reaction.action} for {reaction.reaction_name}')
         self.callback_reaction(reaction)
 
+    @lru_cache(1024)
+    def email_to_userid(self, email):
+        """Convert an Email to Slack user ID"""
+        user = self.sc.users_lookupByEmail(email=email)
+        if user is None or not user['ok']:
+            raise UserDoesNotExistError(f'Cannot find user with email {email}.')
+        return user['user']['id']
+
     def userid_to_username(self, id_):
         """Convert a Slack user ID to their user name"""
-        user = self.sc.server.users.get(id_)
+        user = self.get_users_info(id_)
         if user is None:
             raise UserDoesNotExistError(f'Cannot find user with ID {id_}.')
-        return user.name
+        return user['name']
 
     def username_to_userid(self, name):
         """Convert a Slack user name to their user ID"""
         name = name.lstrip('@')
-        user = self.sc.server.users.find(name)
-        if user is None:
+        if name == self.auth['user']:
+            return self.bot_identifier.userid
+        try:
+            user = self.get_users_info(name)
+            if user is None:
+                raise SlackApiError
+            return user['id']
+        except SlackApiError as e:
+            log.error(f'Cannot find user {name} - {e.response["error"]}')
             raise UserDoesNotExistError(f'Cannot find user {name}.')
-        return user.id
+        return name
 
+    @lru_cache(1024)
     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:
+        try:
+            channel = self.sc.conversations_info(channel=id_)
+            if not channel:
+                raise SlackApiError
+            return channel['channel']['name']
+        except SlackApiError as e:
             raise RoomDoesNotExistError(f'No channel with ID {id_} exists.')
-        return channel[0].name
+
+    @lru_cache(1024)
+    def get_users_info(self, id_):
+        try:
+            user = self.sc.users_info(user=id_)
+            if user is None or not user['ok']:
+                raise UserDoesNotExistError(f'Cannot find user with ID {id_}.')
+            return user['user']
+        except SlackAPIResponseError:
+            raise UserDoesNotExistError(f'Cannot find user with ID {id_}.')
+
+    @lru_cache(1024)
+    def get_conversations_info(self, name):
+        try:
+            conv = self.sc.conversations_info(channel=name)
+            if conv is None or not conv['ok']:
+                raise RoomDoesNotExistError(f'No channel named {name} exists')
+            return conv['channel']
+        except SlackAPIResponseError:
+            raise RoomDoesNotExistError(f'No channel named {name} exists')
 
     def channelname_to_channelid(self, name):
         """Convert a Slack channel name to its channel ID"""
         name = name.lstrip('#')
-        channel = [channel for channel in self.sc.server.channels if channel.name == name]
+        channel = self.get_conversations_info(name)
         if not channel:
             raise RoomDoesNotExistError(f'No channel named {name} exists')
-        return channel[0].id
+        return channel['id']
 
     def channels(self, exclude_archived=True, joined_only=False, types=room_types):
         """
@@ -618,9 +832,19 @@
         See also:
           * https://api.slack.com/methods/conversations.list
         """
-        response = self.api_call('conversations.list', data={'exclude_archived': exclude_archived, 'types': types})
-        channels = [channel for channel in response['channels']
-                    if channel['is_member'] or not joined_only]
+        channels = list()
+        next_results = True
+
+        while next_results:
+            query = {
+                'exclude_archived': exclude_archived,
+                'limit': 1000,
+                'cursor': next_results if isinstance(next_results, str) else None,
+                'types': types
+            }
+            response = self.api_call('conversations.list', query)
+            channels.extend([x for x in response['channels'] if x['is_member'] or not joined_only])
+            next_results = response['response_metadata'].get('next_cursor', None)
 
         # There is no need to list groups anymore.  Groups are now identified as 'private_channel'
         # type using the conversations.list api method.
@@ -638,8 +862,8 @@
         try:
             response = self.api_call('conversations.open', data={'users': id_})
             return response['channel']['id']
-        except SlackAPIResponseError as e:
-            if e.error == "cannot_dm_bot":
+        except SlackApiError as e:
+            if e['error'] == "cannot_dm_bot":
                 log.info('Tried to DM a bot.')
                 return None
             else:
@@ -659,7 +883,7 @@
             to_channel_id = msg.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(msg.to.username))
+                to_channel_id = self.get_im_channel(msg.to.userid)
         return to_humanreadable, to_channel_id
 
     def send_message(self, msg):
@@ -683,14 +907,14 @@
                 to_humanreadable = msg.to.username
                 if isinstance(msg.to, RoomOccupant):  # private to a room occupant -> this is a divert to private !
                     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(msg.to.username))
+                    to_channel_id = self.get_im_channel(msg.to.userid)
                 else:
                     to_channel_id = msg.to.channelid
 
             msgtype = "direct" if msg.is_direct else "channel"
-            log.debug('Sending %s message to %s (%s).', msgtype, to_humanreadable, to_channel_id)
+            log.debug(f'Sending {msgtype} message to {to_humanreadable} ({to_channel_id}).')
             body = self.md.convert(msg.body)
-            log.debug('Message size: %d.', len(body))
+            log.debug(f'Message size: {len(body)}.')
 
             parts = self.prepare_message_body(body, self.message_size_limit)
 
@@ -702,13 +926,21 @@
                     'unfurl_media': 'true',
                     'link_names': '1',
                     'as_user': 'true',
+                    'attachments': msg.extras.get('attachments', None),
                 }
 
                 # Keep the thread_ts to answer to the same thread.
                 if 'thread_ts' in msg.extras:
                     data['thread_ts'] = msg.extras['thread_ts']
 
-                result = self.api_call('chat.postMessage', data=data)
+                method = 'chat.postMessage'
+                if msg.extras.get('ephemeral'):
+                    method = 'chat.postEphemeral'
+                    data['user'] = msg.to.userid
+                    if isinstance(msg.to, RoomOccupant):
+                        data['channel'] = msg.to.channelid
+
+                result = self.api_call(method, data=data)
                 timestamps.append(result['ts'])
 
             msg.extras['ts'] = timestamps
@@ -724,11 +956,11 @@
         """
         try:
             stream.accept()
-            resp = self.api_call('files.upload', data={
-                'channels': stream.identifier.channelid,
-                'filename': stream.name,
-                'file': stream
-            })
+            resp = self.sc.files_upload(
+                channels=stream.identifier.channelid,
+                filename=stream.name,
+                file=stream
+            )
             if 'ok' in resp and resp['ok']:
                 stream.success()
             else:
@@ -754,8 +986,7 @@
             :return Stream: object on which you can monitor the progress of it.
         """
         stream = Stream(user, fsource, name, size, stream_type)
-        log.debug('Requesting upload of %s to %s (size hint: %d, stream type: %s).',
-                  name, user.channelname, size, stream_type)
+        log.debug(f'Requesting upload of {name} to {user.channelid} (stream type: {stream_type})')
         self.thread_pool.apply_async(self._slack_upload, (stream,))
         return stream
 
@@ -795,7 +1026,7 @@
                 'as_user': 'true'
             }
             try:
-                log.debug('Sending data:\n%s', data)
+                log.debug(f'Sending data:\n{data}')
                 self.api_call('chat.postMessage', data=data)
             except Exception:
                 log.exception(f'An exception occurred while trying to send a card to {to_humanreadable}.[{card}]')
@@ -850,6 +1081,7 @@
         Supports strings with the following formats::
 
             <#C12345>
+            <#C12345|channel>
             <@U12345>
             <@U12345|user>
             @user
@@ -884,7 +1116,10 @@
                 else:
                     userid = text
             elif text[0] in ('C', 'G', 'D'):
-                channelid = text
+                if '|' in text:
+                    channelid, channelname = text.split('|')
+                else:
+                    channelid = text
             else:
                 raise ValueError(exception_message % text)
         elif text[0] == '@':
@@ -907,7 +1142,7 @@
         Supports strings with the formats accepted by
         :func:`~extract_identifiers_from_string`.
         """
-        log.debug('building an identifier from %s.', txtrep)
+        log.debug(f'building an identifier from {txtrep}.')
         username, userid, channelname, channelid = self.extract_identifiers_from_string(txtrep)
 
         if userid is None and username is not None:
@@ -915,9 +1150,11 @@
         if channelid is None and channelname is not None:
             channelid = self.channelname_to_channelid(channelname)
         if userid is not None and channelid is not None:
-            return SlackRoomOccupant(self.sc, userid, channelid, bot=self)
+            return SlackRoomOccupant(userid, channelid, bot=self)
         if userid is not None:
-            return SlackPerson(self.sc, userid, self.get_im_channel(userid))
+            if userid == self.bot_identifier.userid:
+                return self.bot_identifier
+            return SlackPerson(userid, self.get_im_channel(userid), bot=self)
         if channelid is not None:
             return SlackRoom(channelid=channelid, bot=self)
 
@@ -994,6 +1231,7 @@
             return msg.extras['slack_event']['ts']
 
     def shutdown(self):
+        self.running = False
         super().shutdown()
 
     @property
@@ -1019,7 +1257,7 @@
             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]
+        return [SlackRoom(name=channel['name'], channelid=channel['id'], bot=self) for channel in channels]
 
     def prefix_groupchat_reply(self, message, identifier):
         super().prefix_groupchat_reply(message, identifier)
@@ -1050,39 +1288,38 @@
         """
         mentioned = []
 
-        m = re.findall('<@[^>]*>*', text)
+        m = re.findall('<[@#][^>]*>*', text)
 
         for word in m:
             try:
                 identifier = self.build_identifier(word)
             except Exception as e:
-                log.debug("Tried to build an identifier from '%s' but got exception: %s", word, e)
+                log.debug(f"Tried to build an identifier from '{word}' but got exception: {e}")
                 continue
 
             # We only track mentions of persons.
             if isinstance(identifier, SlackPerson):
-                log.debug('Someone mentioned')
+                log.debug(f'Someone mentioned user {identifier}')
+                mentioned.append(identifier)
+                text = text.replace(word, f'@{identifier.userid}')
+            elif isinstance(identifier, SlackRoom):
+                log.debug(f'Someone mentioned channel {identifier}')
                 mentioned.append(identifier)
-                text = text.replace(word, str(identifier))
+                text = text.replace(word, f'#{identifier.channelid}')
 
         return text, mentioned
 
 
 class SlackRoom(Room):
     def __init__(self, name=None, channelid=None, bot=None):
-        if channelid is not None and name is not None:
-            raise ValueError("channelid and name are mutually exclusive")
-
         if name is not None:
-            if name.startswith('#'):
-                self._name = name[1:]
-            else:
-                self._name = name
+            self._name = name.replace('#', '')
         else:
             self._name = bot.channelid_to_channelname(channelid)
 
-        self._id = None
+        self._id = channelid
         self._bot = bot
+        self._info = None
         self.sc = bot.sc
 
     def __str__(self):
@@ -1097,22 +1334,23 @@
         """
         The channel object exposed by SlackClient
         """
-        id_ = self.sc.server.channels.find(self.name)
-        if id_ is None:
-            raise RoomDoesNotExistError(f"{str(self)} does not exist (or is a private group you don't have access to)")
-        return id_
+        if self._id:
+            return self
+        return self.info.get('id')
 
     @property
-    def _channel_info(self):
-        """
-        Channel info as returned by the Slack API.
-
-        See also:
-          * https://api.slack.com/methods/conversations.list
-            Removed the groups.info call.  Conversations.info covers it all
-        """
-
-        return self._bot.api_call('conversations.info', data={'channel': self.id})["channel"]
+    def info(self):
+        if self._info is not None:
+            return self._info
+        try:
+            info = self._bot.get_conversations_info(self.id)
+            if info:
+                self._info = info
+                return self._info
+        except SlackApiError:
+            raise RoomDoesNotExistError(f"{str(self)} does not exist (or is a private group you don't have access to)")
+            return dict()
+        return dict()
 
     @property
     def _channel_members(self):
@@ -1144,9 +1382,10 @@
         return self._name
 
     def join(self, username=None, password=None):
-        log.info("Joining channel %s", str(self))
+        log.info(f'Joining channel {self}')
         try:
             self._bot.api_call('conversations.join', data={'channel': self.id})
+            return True
         except SlackAPIResponseError as e:
             if e.error == 'user_is_bot':
                 raise RoomError(f'Unable to join channel. {USER_IS_BOT_HELPTEXT}')
@@ -1156,10 +1395,10 @@
     def leave(self, reason=None):
         try:
             if self.id.startswith('C'):
-                log.info('Leaving channel %s (%s)', self, self.id)
+                log.info(f'Leaving channel {self} ({self.id})')
                 self._bot.api_call('conversations.leave', data={'channel': self.id})
             else:
-                log.info('Leaving group %s (%s)', self, self.id)
+                log.info(f'Leaving group {self} ({self.id})')
                 self._bot.api_call('conversations.leave', data={'channel': self.id})
         except SlackAPIResponseError as e:
             if e.error == 'user_is_bot':
@@ -1171,10 +1410,10 @@
     def create(self, private=False):
         try:
             if private:
-                log.info('Creating private channel %s.', self)
+                log.info(f'Creating private channel {self}.')
                 self._bot.api_call('conversations.create', data={'name': self.name, 'is_private': True})
             else:
-                log.info('Creating channel %s.', self)
+                log.info(f'Creating channel {self}.')
                 self._bot.api_call('conversations.create', data={'name': self.name})
         except SlackAPIResponseError as e:
             if e.error == 'user_is_bot':
@@ -1185,10 +1424,10 @@
     def destroy(self):
         try:
             if self.id.startswith('C'):
-                log.info('Archiving channel %s (%s)', self, self.id)
+                log.info(f'Archiving channel {self} ({self.id})')
                 self._bot.api_call('conversations.archive', data={'channel': self.id})
             else:
-                log.info('Archiving group %s (%s)', self, self.id)
+                log.info(f'Archiving group {self} ({self.id})')
                 self._bot.api_call('conversations.archive', data={'channel': self.id})
         except SlackAPIResponseError as e:
             if e.error == 'user_is_bot':
@@ -1197,6 +1436,17 @@
                 raise RoomError(e)
         self._id = None
 
+    def rename(self, name):
+        try:
+            log.info(f'Renaming channel {self} to {name} ({self.id})')
+            self.sc.conversations_rename(channel=self.id, name=name)
+            self._name = name
+        except SlackAPIResponseError as e:
+            if e.error == 'user_is_bot':
+                raise RoomError(f'Unable to archive channel. {USER_IS_BOT_HELPTEXT}')
+            else:
+                raise RoomError(e)
+
     @property
     def exists(self):
         channels = self._bot.channels(joined_only=False, exclude_archived=False)
@@ -1209,55 +1459,73 @@
 
     @property
     def topic(self):
-        if self._channel_info['topic']['value'] == '':
+        if self.info['topic']['value'] == '':
             return None
         else:
-            return self._channel_info['topic']['value']
+            return self.info['topic']['value']
 
     @topic.setter
     def topic(self, topic):
         # No need to separate groups from channels here anymore.
 
-        log.info('Setting topic of %s (%s) to %s.', self, self.id, topic)
+        log.info(f'Setting topic of {self} ({self.id}) to {topic}.')
         self._bot.api_call('conversations.setTopic', data={'channel': self.id, 'topic': topic})
+        # update topic
+        self._info['topic']['value'] = topic
 
     @property
     def purpose(self):
-        if self._channel_info['purpose']['value'] == '':
+        if self.info['purpose']['value'] == '':
             return None
         else:
-            return self._channel_info['purpose']['value']
+            return self.info['purpose']['value']
 
     @purpose.setter
     def purpose(self, purpose):
         # No need to separate groups from channels here anymore.
 
-        log.info('Setting purpose of %s (%s) to %s.', str(self), self.id, purpose)
+        log.info(f'Setting purpose of {self} ({self.id}) to {purpose}.')
         self._bot.api_call('conversations.setPurpose', data={'channel': self.id, 'purpose': purpose})
 
     @property
     def occupants(self):
         members = self._channel_members['members']
-        return [SlackRoomOccupant(self.sc, m, self.id, self._bot) for m in members]
+        return [SlackRoomOccupant(m, self.id, self._bot) for m in members]
 
     def invite(self, *args):
-        users = {user['name']: user['id'] for user in self._bot.api_call('users.list')['members']}
         for user in args:
-            if user not in users:
-                raise UserDoesNotExistError(f'User "{user}" not found.')
-            log.info('Inviting %s into %s (%s)', user, self, self.id)
-            method = 'conversations.invite'
-            response = self._bot.api_call(
-                method,
-                data={'channel': self.id, 'user': users[user]},
-                raise_errors=False
-            )
-
-            if not response['ok']:
-                if response['error'] == 'user_is_bot':
+            if isinstance(user, SlackPerson):
+                if user == self._bot.bot_identifier:
+                    continue
+                user = user.userid
+            log.info(f'Inviting {user} into {self} ({self.id})')
+            try:
+                response = self.sc.conversations_invite(users=user, channel=self.id)
+            except SlackApiError as e:
+                if e.response.get('error') == 'already_in_channel':
+                    pass  # nothing to do
+                elif e.response.get('error') == 'not_in_channel':
+                    log.warning(f'Not in channel {self.id}. Trying to join.')
+                    if self.join():  # try to join and reinvite
+                        self.sc.conversations_invite(users=user, channel=self.id)
+                        pass
+                elif e.response.get('error') == 'user_is_bot':
                     raise RoomError(f'Unable to invite people. {USER_IS_BOT_HELPTEXT}')
-                elif response['error'] != 'already_in_channel':
-                    raise SlackAPIResponseError(error=f'Slack API call to {method} failed: {response["error"]}.')
+                else:
+                    raise SlackAPIResponseError(error=f'Slack API conversations.invite failed: {e.response["error"]}.')
+
+    def kick(self, user):
+        if isinstance(user, SlackPerson):
+            if user == self._bot.bot_identifier:
+                return self.leave()
+            user = user.userid
+
+        try:
+            response = self.sc.conversations_kick(channel=self.id, user=user)
+            if response['ok'] or response['error'] == 'not_in_channel':
+                return True
+        except SlackApiError as e:
+            return False
 
     def __eq__(self, other):
         if not isinstance(other, SlackRoom):
@duhow duhow mentioned this issue Jan 20, 2021
@nzlosh
Copy link
Collaborator

nzlosh commented Jan 20, 2021

This is a big patch set. How would you like to integrate your changes? Perhaps create a branch and slowly add your changes then merge to master?

@nzlosh
Copy link
Collaborator

nzlosh commented Jan 20, 2021

It looks like there are already some changes that were already applied to the slackv3 backend which will make for less work when applying your changes.

@duhow
Copy link
Collaborator Author

duhow commented Jan 20, 2021

Some key features / changes:

  • Removed Slack object in Room, RoomOccupant and Person. Instead I'm using the full self (backend) to get to parent functions.
    This includes cached functions.
  • From Person, added info calling to cached function API users.info to get full name, email and other needed properties.
  • From Person, username is renamed to display_name or real_name normalized. https://api.slack.com/changelog/2017-09-the-one-about-usernames
  • You can create a RoomOccupant with a Room object instead of channelid.
  • Defined default webhook to Flask as '/slack/events'. This will trigger Errbot Flask and run _dispatch_slack_message function.
  • Added support for interactive messages (dialogs, modals) as Messages with flow support. It's still tricky.
  • Added support for callback_room_joined and callback_room_left for RoomOccupant (other Persons), not only bot.
  • Caching email_to_userid, channelid_to_channelname, get_users_info, get_conversations_info
  • Added support for Message attachments as extra field when running send_message
  • Added support for ephemeral messages (chat.postEphemeral) as extra field
  • From Room, added rename and kick functions.

Just be aware this backend is tweaked to my needs for the Slack bot, some functions are not fully working or developed (some old code remains).

@andrewthetechie
Copy link

Similar to @duhow, I built my own Slack backend to add some functions we needed. It's here: https://github.com/andrewthetechie/err-slackextendedbackend

Dunno how helpful it'll be, but wanted to share it on

@nzlosh
Copy link
Collaborator

nzlosh commented Feb 8, 2021

@andrewthetechie Thank you for pointing this out!! The slack backend story has been unfortunate for errbot. It appears there were 4-5 developers that all implemented some form of slack backend because the core backend wasn't evolving fast enough. Would you be interested in proposing patches to port your extended features to this backend?

@andrewthetechie
Copy link

Right now I probably can't invest the time to do patches. However, I will keep an eye on this repo (starred it) and try to provide feedback/feature requests.

When I get some time, I'll see about moving my testbot to this backend to start dogfooding and offering feedback.

The biggest thing I'd offer is "I want to be able to do a callback on any slack event". That's mostly what lead to us building our own backend - we wanted to react to other events and couldn't come up with a good way to make it happen on the existing backend.

@nzlosh
Copy link
Collaborator

nzlosh commented Feb 8, 2021

Fair enough, I understand where you're coming from.

The plumbing is there to register handlers for most slack events. In theory you could inherit from the backend to implement your own handler methods to have the bot react to them as you wish. However, there is a bug errbotio/errbot#1484 (comment) which is causing inheritance to break for backends at the moment. The callback model is quite simple and may not be adapted to complex use cases. It could be something that evolves in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants