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()
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()
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()