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