From b1a6408eca4834f7f6b557c35fdf137cdacb074f Mon Sep 17 00:00:00 2001 From: Sijis Aviles Date: Wed, 20 Jul 2022 22:04:24 -0500 Subject: [PATCH] refactor: remove built-in slack backend (#1581) * refactor: remove slack backend * refactor: remove slack-rtm backend * test: remove slack tests * docs: remove slack and use slackv3 * chore: add slackv3 build to Dockerfile * feat: enable backend plugins directory on --init * chore: add slackv3 repo to backend image * refactor: simplify the final image * docs: fix slack refs in README * docs: add info to CHANGES --- CHANGES.rst | 4 + Dockerfile | 25 +- README.rst | 12 +- docs/requirements.txt | 7 +- docs/user_guide/configuration/slack.rst | 90 -- docs/user_guide/configuration/slackv3.rst | 7 +- docs/user_guide/setup.rst | 1 - errbot/backends/slack.plug | 6 - errbot/backends/slack.py | 1410 --------------------- errbot/backends/slack_rtm.plug | 6 - errbot/backends/slack_rtm.py | 1361 -------------------- errbot/cli.py | 3 + errbot/templates/initdir/config.py.tmpl | 1 + setup.py | 6 - tests/backend_tests/slack_test.py | 304 ----- 15 files changed, 36 insertions(+), 3207 deletions(-) delete mode 100644 docs/user_guide/configuration/slack.rst delete mode 100644 errbot/backends/slack.plug delete mode 100644 errbot/backends/slack.py delete mode 100644 errbot/backends/slack_rtm.plug delete mode 100644 errbot/backends/slack_rtm.py delete mode 100644 tests/backend_tests/slack_test.py diff --git a/CHANGES.rst b/CHANGES.rst index 17a4b688b..d3be1d7e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ v9.9.9 (unreleased) ------------------- +breaking: + +- backend/slack: remove slack and slack_rtm built-in backends (#1581) + features: - core/plugins: detect plugins using entrypoints (#1590) diff --git a/Dockerfile b/Dockerfile index 98f973f38..0cd0d4db4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,30 @@ -FROM python:3.9-slim as BUILD +FROM python:3.9-slim as build WORKDIR /wheel COPY . . -RUN apt update && apt install build-essential -y +RUN apt update && apt install -y build-essential git +RUN cd /tmp && \ + git clone https://github.com/errbotio/err-backend-slackv3 slackv3 RUN pip3 wheel --wheel-dir=/wheel . \ - errbot errbot[irc] errbot[slack] errbot[XMPP] errbot[telegram] + -r /tmp/slackv3/requirements.txt wheel \ + errbot errbot[irc] errbot[XMPP] errbot[telegram] && \ + cp /tmp/slackv3/requirements.txt /wheel/slackv3-requirements.txt -FROM python:3.9-slim -COPY --from=BUILD /wheel /wheel +FROM python:3.9-slim as base +COPY --from=build /wheel /wheel RUN apt update && \ apt install -y git && \ cd /wheel && \ pip3 -vv install --no-cache-dir --no-index --find-links /wheel . \ - errbot errbot[irc] errbot[slack] errbot[XMPP] errbot[telegram] && \ + -r /wheel/slackv3-requirements.txt \ + errbot errbot[irc] errbot[XMPP] errbot[telegram] && \ rm -rf /wheel /var/lib/apt/lists/* - RUN useradd -m errbot + +FROM base +EXPOSE 3141 3142 +VOLUME /home/errbot WORKDIR /home/errbot USER errbot RUN errbot --init -EXPOSE 3141 3142 -VOLUME /home/errbot +RUN git clone https://github.com/errbotio/err-backend-slackv3 backend-plugins/slackv3 ENTRYPOINT [ "/usr/local/bin/errbot" ] diff --git a/README.rst b/README.rst index f5cd653c8..24166cbde 100644 --- a/README.rst +++ b/README.rst @@ -43,15 +43,15 @@ Chat servers support **Built-in** - IRC support -- `Slack support `_ - `Telegram support `_ - `XMPP support `_ **With add-ons** -- `Webex `_ (See `instructions `__) -- `Discord `_ (See `instructions `__) +- `Slack support `_ (See `instructions `__) +- `Discord `_ (See `instructions `__) - `Gitter support `_ (See `instructions `__) +- `Webex `_ (See `instructions `__) - `Mattermost `_ (See `instructions `__) - `RocketChat `_ (See `instructions `__) - `Skype `_ (See `instructions `__) @@ -130,12 +130,12 @@ It will show you a prompt `>>>` so you can talk to your bot directly! Try `!help Adding support for a chat system ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For the built-ins, just use one of those options `slack, telegram, IRC, XMPP` with pip, you can still do it +For the built-ins, just use one of those options `telegram, IRC, XMPP` with pip, you can still do it after the initial installation to add the missing support for example :: - $ pip install "errbot[slack]" + $ pip install "errbot[irc]" -For the external ones (Skype, Gitter, Discord etc ...), please follow their respective github pages for instructions. +For the external ones (Slack, Discord, Gitter, Skype, etc ...), please follow their respective github pages for instructions. Configuration ~~~~~~~~~~~~~ diff --git a/docs/requirements.txt b/docs/requirements.txt index 37ceb29dc..6ba2c7197 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,9 +3,8 @@ sphinx>=1.2 sphinx-autodoc-annotation -e . -slixmpp -irc -python-telegram-bot -slackclient hypchat +irc pytest +python-telegram-bot +slixmpp diff --git a/docs/user_guide/configuration/slack.rst b/docs/user_guide/configuration/slack.rst deleted file mode 100644 index 3874d2b52..000000000 --- a/docs/user_guide/configuration/slack.rst +++ /dev/null @@ -1,90 +0,0 @@ -Slack backend configuration -=========================== - -This backend lets you connect to the -`Slack `_ messaging service. -To select this backend, -set `BACKEND = 'Slack'`. - -Extra Dependencies ------------------- - -You need to install this dependency before using Errbot with Slack:: - - pip install slackclient - -Account setup -------------- - -You will need to have an account at Slack for the bot to use, -either a bot account (recommended) or a regular user account. - -We will assume you're using a bot account for errbot, -which `may be created here `_. -Make note of the **API Token** you receive as you will need it next. - -With the bot account created on Slack, -you may configure the account in errbot -by setting up `BOT_IDENTITY` as follows:: - - BOT_IDENTITY = { - 'token': 'xoxb-4426949411-aEM7...', - } - - -Proxy setup -------------- - -In case you need to use a Proxy to connect to Slack, -you can set the proxies with the token config. - - BOT_IDENTITY = { - 'token': 'xoxb-4426949411-aEM7...', - 'proxies': {'http': 'some-http-proxy', 'https': 'some-https-proxy'} - } - - -Bot admins ----------- - -You can set `BOT_ADMINS` to configure which Slack users are bot administrators. -Make sure to include the `@` sign:: - - BOT_ADMINS = ('@gbin', '@zoni') - - -Bot mentions using @ --------------------- - -To enable using the bot's name in `BOT_ALT_PREFIXES` for @mentions in Slack, simply add the bot's name as follows:: - - BOT_ALT_PREFIXES = ('@botname',) - - -Channels/groups ---------------- - -If you're using a bot account you should set `CHATROOM_PRESENCE = ()`. -Bot accounts on Slack are not allowed to join/leave channels on their own -(they must be invited by a user instead) -so having any rooms setup in `CHATROOM_PRESENCE` will result in an error. - -If you are using a regular user account for the bot -then you can set `CHATROOM_PRESENCE` to a list of channels and groups to join. - -.. note:: - - You may leave the value for `CHATROOM_FN` at its default - as it is ignored by this backend. - - -Message size limit ------------------- - -As of the 12th August 2018 the Slack API has a message limit size of 40,000 characters. Messages -larger than 40,000 will be truncated by Slack's API. Errbot includes the functionality to split -messages larger than 40,000 characters into multiple parts. To reduce the message limit size, set the -`MESSAGE_SIZE_LIMIT` variable in the configuration file. Errbot will use the smallest value between -the default 40,000 and `MESSAGE_SIZE_LIMIT`. - -#MESSAGE_SIZE_LIMIT = 1000 diff --git a/docs/user_guide/configuration/slackv3.rst b/docs/user_guide/configuration/slackv3.rst index 7478856da..61d57c76e 100644 --- a/docs/user_guide/configuration/slackv3.rst +++ b/docs/user_guide/configuration/slackv3.rst @@ -17,12 +17,10 @@ You need to install Slackv3 dependencies before using Errbot with Slack. In the it is assumed slackv3 has been download to the /opt/errbot/backends directory and errbot has been installed in a python virtual environment (adjust the command to your errbot's installation):: -.. code:: - + git clone https://github.com/errbotio/err-backend-slackv3.git source /opt/errbot/bin/activate /opt/errbot/bin/pip install -r /opt/errbot/backends/err-backend-slackv3/requirements.txt - Connection Methods ------------------ @@ -87,7 +85,7 @@ The virtual environment is created in `/opt/errbot/virtualenv` and errbot initia git clone https://github.com/errbotio/err-backend-slackv3 pip install -r /opt/errbot/backend/err-backend-slackv3/requirements.txt -5. Configure the slack bot token, signing secret (Events API with Request URLs) and/or app token (Events API with Socket-mode). Located in ``/opt/errbot/config.py`` +5. Configure the slack bot token, signing secret (Events API with Request URLs) and/or app token (Events API with Socket-mode). Located in `/opt/errbot/config.py` .. code:: @@ -97,6 +95,7 @@ The virtual environment is created in `/opt/errbot/virtualenv` and errbot initia 'app_token': "xapp-..." } + Setting up Slack application ---------------------------- diff --git a/docs/user_guide/setup.rst b/docs/user_guide/setup.rst index 9ad8b5489..0cbfcdc9c 100644 --- a/docs/user_guide/setup.rst +++ b/docs/user_guide/setup.rst @@ -108,7 +108,6 @@ You can find here more details about configuring Errbot for some specific chat s configuration/gitter configuration/irc configuration/mattermost - configuration/slack configuration/slackv3 configuration/telegram configuration/xmpp diff --git a/errbot/backends/slack.plug b/errbot/backends/slack.plug deleted file mode 100644 index bbf55fa30..000000000 --- a/errbot/backends/slack.plug +++ /dev/null @@ -1,6 +0,0 @@ -[Core] -Name = Slack -Module = slack - -[Documentation] -Description = This is the slack backend for Errbot. diff --git a/errbot/backends/slack.py b/errbot/backends/slack.py deleted file mode 100644 index a9c459326..000000000 --- a/errbot/backends/slack.py +++ /dev/null @@ -1,1410 +0,0 @@ -import collections -import copyreg -import json -import logging -import pprint -import re -import sys -from functools import lru_cache -from typing import BinaryIO - -from markdown import Markdown -from markdown.extensions.extra import ExtraExtension -from markdown.preprocessors import Preprocessor - -from errbot.backends.base import ( - AWAY, - ONLINE, - REACTION_ADDED, - REACTION_REMOVED, - Card, - Identifier, - Message, - Person, - Presence, - Reaction, - Room, - RoomDoesNotExistError, - RoomError, - RoomOccupant, - Stream, - UserDoesNotExistError, -) -from errbot.core import ErrBot -from errbot.rendering.ansiext import IMTEXT_CHRS, AnsiExtension, enable_format -from errbot.utils import split_string_after - -log = logging.getLogger(__name__) - -try: - from slackclient import SlackClient -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" - ) - sys.exit(1) - -# The Slack client automatically turns a channel name into a clickable -# link if you prefix it with a #. Other clients receive this link as a -# token matching this regex. -SLACK_CLIENT_CHANNEL_HYPERLINK = re.compile(r"^<#(?P(C|G)[0-9A-Z]+)>$") - - -USER_IS_BOT_HELPTEXT = ( - "Connected to Slack using a bot account, which cannot manage " - "channels itself (you must invite the bot to channels instead, " - "it will auto-accept) nor invite people.\n\n" - "If you need this functionality, you will have to create a " - "regular user account and connect Errbot using that account. " - "For this, you will also need to generate a user token at " - "https://api.slack.com/web." -) - -COLORS = { - "red": "#FF0000", - "green": "#008000", - "yellow": "#FFA500", - "blue": "#0000FF", - "white": "#FFFFFF", - "cyan": "#00FFFF", -} # Slack doesn't know its colors - -MARKDOWN_LINK_REGEX = re.compile( - r"(?[^\]]+?)\]\((?P[a-zA-Z0-9]+?:\S+?)\)" -) - - -def slack_markdown_converter(compact_output=False): - """ - This is a Markdown converter for use with Slack. - """ - enable_format("imtext", IMTEXT_CHRS, borders=not compact_output) - md = Markdown( - output_format="imtext", extensions=[ExtraExtension(), AnsiExtension()] - ) - md.preprocessors.register(LinkPreProcessor(md), "LinkPreProcessor", 30) - md.stripTopLevelTags = False - return md - - -class LinkPreProcessor(Preprocessor): - """ - This preprocessor converts markdown URL notation into Slack URL notation - as described at https://api.slack.com/docs/formatting, section "Linking to URLs". - """ - - def run(self, lines): - for i, line in enumerate(lines): - lines[i] = MARKDOWN_LINK_REGEX.sub(r"<\2|\1>", line) - return lines - - -class SlackAPIResponseError(RuntimeError): - """Slack API returned a non-OK response""" - - def __init__(self, *args, error="", **kwargs): - """ - :param error: - The 'error' key from the API response data - """ - self.error = error - super().__init__(*args, **kwargs) - - -class SlackPerson(Person): - """ - This class describes a person on Slack's network. - """ - - def __init__(self, sc, userid=None, channelid=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)" - ) - - if channelid is not None and channelid[0] not in ("D", "C", "G"): - raise Exception( - f"This is not a valid Slack channelid: {channelid} (should start with D, C or G)" - ) - - self._userid = userid - self._channelid = channelid - self._sc = sc - - @property - def userid(self): - return self._userid - - @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 - - @property - def channelid(self): - return self._channelid - - @property - def channelname(self): - """Convert a Slack channel ID to its channel name""" - if self._channelid is None: - return None - - channel = self._sc.server.channels.find(self._channelid) - if channel is None: - raise RoomDoesNotExistError(f"No channel with ID {self._channelid} exists.") - return channel.name - - @property - def domain(self): - return self._sc.server.domain - - # Compatibility with the generic API. - client = channelid - nick = username - - # Override for ACLs - @property - def aclattr(self): - # Note: Don't use str(self) here because that will return - # an incorrect format from SlackMUCOccupant. - return f"@{self.username}" - - @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 - - @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 - - def __unicode__(self): - return f"@{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, SlackPerson): - log.warning("tried to compare a SlackPerson with a %s", type(other)) - return False - return other.userid == self.userid - - def __hash__(self): - return self.userid.__hash__() - - @property - def person(self): - # Don't use str(self) here because we want SlackRoomOccupant - # to return just our @username too. - return f"@{self.username}" - - -class SlackRoomOccupant(RoomOccupant, SlackPerson): - """ - 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) - - @property - def room(self): - return self._room - - def __unicode__(self): - return f"#{self._room.name}/{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, RoomOccupant): - log.warning( - "tried to compare a SlackRoomOccupant with a SlackPerson %s vs %s", - self, - other, - ) - return False - return other.room.id == self.room.id and other.userid == self.userid - - -class SlackBot(SlackPerson): - """ - This class describes a bot on Slack's network. - """ - - def __init__(self, sc, bot_id, bot_username): - self._bot_id = bot_id - self._bot_username = bot_username - super().__init__(sc=sc, userid=bot_id) - - @property - def username(self): - return self._bot_username - - # Beware of gotcha. Without this, nick would point to username of SlackPerson. - nick = username - - @property - def aclattr(self): - # Make ACLs match against integration ID rather than human-readable - # nicknames to avoid webhooks impersonating other people. - return f"<{self._bot_id}>" - - @property - def fullname(self): - return None - - -class SlackRoomBot(RoomOccupant, SlackBot): - """ - 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) - - @property - def room(self): - return self._room - - def __unicode__(self): - return f"#{self._room.name}/{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, RoomOccupant): - log.warning( - "tried to compare a SlackRoomBotOccupant with a SlackPerson %s vs %s", - self, - other, - ) - return False - return other.room.id == self.room.id and other.userid == self.userid - - -class SlackBackend(ErrBot): - - room_types = "public_channel,private_channel" - - @staticmethod - def _unpickle_identifier(identifier_str): - return SlackBackend.__build_identifier(identifier_str) - - @staticmethod - def _pickle_identifier(identifier): - return SlackBackend._unpickle_identifier, (str(identifier),) - - def _register_identifiers_pickling(self): - """ - Register identifiers pickling. - - As Slack needs live objects in its identifiers, we need to override their pickling behavior. - 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 - for cls in (SlackPerson, SlackRoomOccupant, SlackRoom): - copyreg.pickle( - cls, SlackBackend._pickle_identifier, SlackBackend._unpickle_identifier - ) - - def __init__(self, config): - super().__init__(config) - identity = config.BOT_IDENTITY - self.token = identity.get("token", None) - self.proxies = identity.get("proxies", 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 - compact = config.COMPACT_OUTPUT if hasattr(config, "COMPACT_OUTPUT") else False - self.md = slack_markdown_converter(compact) - self._register_identifiers_pickling() - - def set_message_size_limit(self, limit=4096, hard_limit=40000): - """ - Slack supports upto 40000 characters per message, Errbot maintains 4096 by default. - """ - super().set_message_size_limit(limit, hard_limit) - - 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: - A dictionary containing 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 = 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")) - - if raise_errors and not response["ok"]: - raise SlackAPIResponseError( - f"Slack API call to {method} failed: {response['error']}", - error=response["error"], - ) - return response - - def update_alternate_prefixes(self): - """Converts BOT_ALT_PREFIXES to use the slack ID instead of name - - Slack only acknowledges direct callouts `@username` in chat if referred - by using the ID of that user. - """ - # convert BOT_ALT_PREFIXES to a list - try: - bot_prefixes = self.bot_config.BOT_ALT_PREFIXES.split(",") - except AttributeError: - bot_prefixes = list(self.bot_config.BOT_ALT_PREFIXES) - - 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, - ) - - 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) - - def serve_once(self): - log.warning( - "This backend is deprecated and will be removed in a future release." - " No future updates, bug fixes or enhancements will be included." - " We strongly advise migrating to SlackV3, which is available" - " at https://github.com/errbotio/err-backend-slackv3." - ) - self.sc = SlackClient(self.token, proxies=self.proxies) - - log.info("Verifying authentication token") - 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"]) - - 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() - - # Inject bot identity to alternative prefixes - self.update_alternate_prefixes() - - 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 ?") - - 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, - "message": self._message_event_handler, - "member_joined_channel": self._member_joined_channel_event_handler, - "reaction_added": self._reaction_event_handler, - "reaction_removed": self._reaction_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(f"{event_type} event handler raised an exception") - - 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 = SlackPerson(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( - f"It appears the Slack API changed, I received an unknown presence type {presence}." - ) - status = ONLINE - self.callback_presence(Presence(identifier=idd, status=status)) - - def _message_event_handler(self, event): - """Event handler for the 'message' event""" - channel = event["channel"] - if channel[0] not in "CGD": - log.warning("Unknown message type! Unable to handle %s", 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) - 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"].get("text", "") - user = event["message"].get("user", event.get("bot_id")) - else: - text = event.get("text", "") - user = event.get("user", event.get("bot_id")) - - 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) - - msg = Message( - text, - extras={ - "attachments": event.get("attachments"), - "slack_event": event, - }, - ) - - if channel.startswith("D"): - if subtype == "bot_message": - msg.frm = SlackBot( - self.sc, - bot_id=event.get("bot_id"), - bot_username=event.get("username", ""), - ) - else: - msg.frm = SlackPerson(self.sc, user, event["channel"]) - msg.to = SlackPerson( - self.sc, - self.username_to_userid(self.sc.server.username), - event["channel"], - ) - 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, - ) - 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 - - msg.extras["url"] = ( - f"https://{self.sc.server.domain}.slack.com/archives/" - f'{channel_link_name}/p{self._ts_for_message(msg).replace(".", "")}' - ) - - self.callback_message(msg) - - if mentioned: - self.callback_mention(msg, mentioned) - - def _member_joined_channel_event_handler(self, event): - """Event handler for the 'member_joined_channel' event""" - user = SlackPerson(self.sc, event["user"]) - if user == self.bot_identifier: - user = self.bot_identifier - self.callback_room_joined(SlackRoom(channelid=event["channel"], bot=self), user) - - def _reaction_event_handler(self, event): - """Event handler for the 'reaction_added' - and 'reaction_removed' events""" - - user = SlackPerson(self.sc, event["user"]) - item_user = None - if event["item_user"]: - item_user = SlackPerson(self.sc, event["item_user"]) - - action = REACTION_ADDED - if event["type"] == "reaction_removed": - action = REACTION_REMOVED - - reaction = Reaction( - reactor=user, - reacted_to_owner=item_user, - action=action, - timestamp=event["event_ts"], - reaction_name=event["reaction"], - reacted_to=event["item"], - ) - - self.callback_reaction(reaction) - - def userid_to_username(self, id_): - """Convert a Slack user ID to their user name""" - user = self.sc.server.users.get(id_) - if user is None: - raise UserDoesNotExistError(f"Cannot find user with ID {id_}.") - 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: - raise UserDoesNotExistError(f"Cannot find user {name}.") - return user.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(f"No channel with ID {id_} exists.") - return channel[0].name - - 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 - ] - if not channel: - raise RoomDoesNotExistError(f"No channel named {name} exists") - return channel[0].id - - def channels(self, exclude_archived=True, joined_only=False, types=room_types): - """ - 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/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 - ] - - # There is no need to list groups anymore. Groups are now identified as 'private_channel' - # type using the conversations.list api method. - # 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 - - @lru_cache(1024) - def get_im_channel(self, id_): - """Open a direct message channel to a user""" - try: - response = self.api_call("conversations.open", data={"users": id_}) - return response["channel"]["id"] - except SlackAPIResponseError as e: - if e.error == "cannot_dm_bot": - log.info("Tried to DM a bot.") - return None - else: - raise e - - def _prepare_message(self, msg): # or card - """ - Translates the common part of messaging for Slack. - :param msg: the message you want to extract the Slack concept from. - :return: a tuple to user human readable, the channel id - """ - if msg.is_group: - to_channel_id = msg.to.id - to_humanreadable = ( - msg.to.name - if msg.to.name - else self.channelid_to_channelname(to_channel_id) - ) - else: - to_humanreadable = msg.to.username - 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) - ) - return to_humanreadable, to_channel_id - - def send_message(self, msg): - super().send_message(msg) - - if msg.parent is not None: - # we are asked to reply to a specify thread. - try: - msg.extras["thread_ts"] = self._ts_for_message(msg.parent) - except KeyError: - # Gives to the user a more interesting explanation if we cannot find a ts from the parent. - log.exception( - "The provided parent message is not a Slack message " - "or does not contain a Slack timestamp." - ) - - to_humanreadable = "" - try: - if msg.is_group: - to_channel_id = msg.to.id - to_humanreadable = ( - msg.to.name - if msg.to.name - else self.channelid_to_channelname(to_channel_id) - ) - else: - 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) - ) - 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, - ) - body = self.md.convert(msg.body) - log.debug("Message size: %d.", len(body)) - - parts = self.prepare_message_body(body, self.message_size_limit) - - timestamps = [] - for part in parts: - data = { - "channel": to_channel_id, - "text": part, - "unfurl_media": "true", - "link_names": "1", - "as_user": "true", - } - - # 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) - timestamps.append(result["ts"]) - - msg.extras["ts"] = timestamps - except Exception: - log.exception( - f"An exception occurred while trying to send the following message " - f"to {to_humanreadable}: {msg.body}." - ) - - def _slack_upload(self, stream: Stream) -> None: - """ - Performs an upload defined in a stream - :param stream: Stream object - :return: None - """ - try: - stream.accept() - resp = self.api_call( - "files.upload", - data={ - "channels": stream.identifier.channelid, - "filename": stream.name, - "file": stream, - }, - ) - if "ok" in resp and resp["ok"]: - stream.success() - else: - stream.error() - except Exception: - log.exception( - f"Upload of {stream.name} to {stream.identifier.channelname} failed." - ) - - def send_stream_request( - self, - user: Identifier, - fsource: BinaryIO, - name: str = None, - size: int = None, - stream_type: str = None, - ) -> Stream: - """ - Starts a file transfer. For Slack, the size and stream_type are unsupported - - :param user: is the identifier of the person you want to send it to. - :param fsource: is a file object you want to send. - :param name: is an optional filename for it. - :param size: not supported in Slack backend - :param stream_type: not supported in Slack backend - - :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, - ) - self.thread_pool.apply_async(self._slack_upload, (stream,)) - return stream - - def send_card(self, card: Card): - if isinstance(card.to, RoomOccupant): - card.to = card.to.room - to_humanreadable, to_channel_id = self._prepare_message(card) - attachment = {} - if card.summary: - attachment["pretext"] = card.summary - if card.title: - attachment["title"] = card.title - if card.link: - attachment["title_link"] = card.link - if card.image: - attachment["image_url"] = card.image - if card.thumbnail: - attachment["thumb_url"] = card.thumbnail - - if card.color: - attachment["color"] = ( - COLORS[card.color] if card.color in COLORS else card.color - ) - - if card.fields: - attachment["fields"] = [ - {"title": key, "value": value, "short": True} - for key, value in card.fields - ] - - parts = self.prepare_message_body(card.body, self.message_size_limit) - part_count = len(parts) - footer = attachment.get("footer", "") - for i in range(part_count): - if part_count > 1: - attachment["footer"] = f"{footer} [{i + 1}/{part_count}]" - attachment["text"] = parts[i] - data = { - "channel": to_channel_id, - "attachments": json.dumps([attachment]), - "link_names": "1", - "as_user": "true", - } - try: - log.debug("Sending data:\n%s", 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}]" - ) - - def __hash__(self): - return 0 # this is a singleton anyway - - 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> - <@U12345|user> - @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>`, " - "`<@U12345|user>`, `@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, D or W (got `%s`)" - ) - text = text[2:-1] - if text == "": - raise ValueError(exception_message % "") - if text[0] in ("U", "B", "W"): - if "|" in text: - userid, username = text.split("|") - else: - 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 None and username is not None: - userid = self.username_to_userid(username) - 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) - if userid is not None: - return SlackPerson(self.sc, userid, self.get_im_channel(userid)) - if channelid is not None: - return SlackRoom(channelid=channelid, bot=self) - - 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 is_from_self(self, msg: Message) -> bool: - return self.bot_identifier.userid == msg.frm.userid - - def build_reply(self, msg, text=None, private=False, threaded=False): - response = self.build_message(text) - - if "thread_ts" in msg.extras["slack_event"]: - # If we reply to a threaded message, keep it in the thread. - response.extras["thread_ts"] = msg.extras["slack_event"]["thread_ts"] - elif threaded: - # otherwise check if we should start a new thread - response.parent = msg - - response.frm = self.bot_identifier - if private: - response.to = msg.frm - else: - response.to = msg.frm.room if isinstance(msg.frm, RoomOccupant) else msg.frm - return response - - def add_reaction(self, msg: Message, reaction: str) -> None: - """ - Add the specified reaction to the Message if you haven't already. - :param msg: A Message. - :param reaction: A str giving an emoji, without colons before and after. - :raises: ValueError if the emoji doesn't exist. - """ - return self._react("reactions.add", msg, reaction) - - def remove_reaction(self, msg: Message, reaction: str) -> None: - """ - Remove the specified reaction from the Message if it is currently there. - :param msg: A Message. - :param reaction: A str giving an emoji, without colons before and after. - :raises: ValueError if the emoji doesn't exist. - """ - return self._react("reactions.remove", msg, reaction) - - def _react(self, method: str, msg: Message, reaction: str) -> None: - try: - # this logic is from send_message - if msg.is_group: - to_channel_id = msg.to.id - else: - to_channel_id = msg.to.channelid - - ts = self._ts_for_message(msg) - - self.api_call( - method, - data={"channel": to_channel_id, "timestamp": ts, "name": reaction}, - ) - except SlackAPIResponseError as e: - if e.error == "invalid_name": - raise ValueError(e.error, "No such emoji", reaction) - elif e.error in ("no_reaction", "already_reacted"): - # This is common if a message was edited after you reacted to it, and you reacted to it again. - # Chances are you don't care about this. If you do, call api_call() directly. - pass - else: - raise SlackAPIResponseError(error=e.error) - - def _ts_for_message(self, msg): - try: - return msg.extras["slack_event"]["message"]["ts"] - except KeyError: - return msg.extras["slack_event"]["ts"] - - def shutdown(self): - super().shutdown() - - @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): - super().prefix_groupchat_reply(message, identifier) - message.body = f"@{identifier.nick}: {message.body}" - - @staticmethod - def sanitize_uris(text): - """ - Sanitizes URI's present within a slack message. e.g. - , - - - - :returns: - string - """ - text = re.sub(r"<([^|>]+)\|([^|>]+)>", r"\2", text) - text = re.sub(r"<(http([^>]+))>", r"\1", text) - - return text - - def process_mentions(self, text): - """ - Process mentions in a given string - :returns: - A formatted string of the original message - and a list of :class:`~SlackPerson` instances. - """ - mentioned = [] - - 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, - ) - continue - - # We only track mentions of persons. - if isinstance(identifier, SlackPerson): - log.debug("Someone mentioned") - mentioned.append(identifier) - text = text.replace(word, str(identifier)) - - 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 - else: - self._name = bot.channelid_to_channelname(channelid) - - self._id = None - self._bot = bot - self.sc = bot.sc - - def __str__(self): - return f"#{self.name}" - - @property - def channelname(self): - return self._name - - @property - def _channel(self): - """ - 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_ - - @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" - ] - - @property - def _channel_members(self): - """ - Channel members info as returned by the Slack API. - - See also: - * https://api.slack.com/methods/conversations.members - """ - return self._bot.api_call("conversations.members", data={"channel": self.id}) - - @property - def private(self): - """Return True if the room is a private group""" - return self._channel.id.startswith("G") - - @property - def id(self): - """Return the ID of this room""" - if self._id is None: - self._id = self._channel.id - return self._id - - channelid = id - - @property - def name(self): - """Return the name of this room""" - return self._name - - def join(self, username=None, password=None): - log.info("Joining channel %s", str(self)) - try: - self._bot.api_call("conversations.join", data={"channel": self.id}) - except SlackAPIResponseError as e: - if e.error == "user_is_bot": - raise RoomError(f"Unable to join channel. {USER_IS_BOT_HELPTEXT}") - else: - raise RoomError(e) - - def leave(self, reason=None): - try: - if self.id.startswith("C"): - log.info("Leaving channel %s (%s)", self, self.id) - self._bot.api_call("conversations.leave", data={"channel": self.id}) - else: - log.info("Leaving group %s (%s)", self, self.id) - self._bot.api_call("conversations.leave", data={"channel": self.id}) - except SlackAPIResponseError as e: - if e.error == "user_is_bot": - raise RoomError(f"Unable to leave channel. {USER_IS_BOT_HELPTEXT}") - else: - raise RoomError(e) - self._id = None - - def create(self, private=False): - try: - if private: - log.info("Creating private channel %s.", self) - self._bot.api_call( - "conversations.create", data={"name": self.name, "is_private": True} - ) - else: - log.info("Creating channel %s.", self) - self._bot.api_call("conversations.create", data={"name": self.name}) - except SlackAPIResponseError as e: - if e.error == "user_is_bot": - raise RoomError(f"Unable to create channel. {USER_IS_BOT_HELPTEXT}") - else: - raise RoomError(e) - - def destroy(self): - try: - if self.id.startswith("C"): - log.info("Archiving channel %s (%s)", self, self.id) - self._bot.api_call("conversations.archive", data={"channel": self.id}) - else: - log.info("Archiving group %s (%s)", self, self.id) - self._bot.api_call("conversations.archive", data={"channel": self.id}) - 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) - self._id = None - - @property - def exists(self): - channels = self._bot.channels(joined_only=False, exclude_archived=False) - return len([c for c in channels if c["name"] == self.name]) > 0 - - @property - def joined(self): - channels = self._bot.channels(joined_only=True) - return len([c for c in channels if c["name"] == self.name]) > 0 - - @property - def topic(self): - if self._channel_info["topic"]["value"] == "": - return None - else: - return self._channel_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) - self._bot.api_call( - "conversations.setTopic", data={"channel": self.id, "topic": topic} - ) - - @property - def purpose(self): - if self._channel_info["purpose"]["value"] == "": - return None - else: - return self._channel_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) - 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] - - 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": - 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"]}.' - ) - - def __eq__(self, other): - if not isinstance(other, SlackRoom): - return False - return self.id == other.id diff --git a/errbot/backends/slack_rtm.plug b/errbot/backends/slack_rtm.plug deleted file mode 100644 index 9011c20c6..000000000 --- a/errbot/backends/slack_rtm.plug +++ /dev/null @@ -1,6 +0,0 @@ -[Core] -Name = SlackRTM -Module = slack_rtm - -[Documentation] -Description = This is the Slack RTM backend for Errbot. diff --git a/errbot/backends/slack_rtm.py b/errbot/backends/slack_rtm.py deleted file mode 100644 index 1b7c4ec65..000000000 --- a/errbot/backends/slack_rtm.py +++ /dev/null @@ -1,1361 +0,0 @@ -import copyreg -import json -import logging -import pprint -import re -import sys -from functools import lru_cache -from time import sleep -from typing import BinaryIO - -from markdown import Markdown -from markdown.extensions.extra import ExtraExtension -from markdown.preprocessors import Preprocessor - -from errbot.backends.base import ( - AWAY, - ONLINE, - Card, - Identifier, - Message, - Person, - Presence, - Room, - RoomDoesNotExistError, - RoomError, - RoomOccupant, - Stream, - UserDoesNotExistError, - UserNotUniqueError, -) -from errbot.core import ErrBot -from errbot.rendering.ansiext import IMTEXT_CHRS, AnsiExtension, enable_format -from errbot.utils import split_string_after - -log = logging.getLogger(__name__) - - -try: - from slack import RTMClient, WebClient - from slack.errors import BotUserAccessError -except ImportError: - log.exception("Could not start the SlackRTM backend") - log.fatal( - "You need to install slackclient in order to use the Slack backend.\n" - "You can do `pip install errbot[slack-rtm]` to install it." - ) - sys.exit(1) - -# The Slack client automatically turns a channel name into a clickable -# link if you prefix it with a #. Other clients receive this link as a -# token matching this regex. -SLACK_CLIENT_CHANNEL_HYPERLINK = re.compile(r"^<#(?P([CG])[0-9A-Z]+)>$") - -# Empirically determined message size limit. -SLACK_MESSAGE_LIMIT = 4096 - -USER_IS_BOT_HELPTEXT = ( - "Connected to Slack using a bot account, which cannot manage " - "channels itself (you must invite the bot to channels instead, " - "it will auto-accept) nor invite people.\n\n" - "If you need this functionality, you will have to create a " - "regular user account and connect Errbot using that account. " - "For this, you will also need to generate a user token at " - "https://api.slack.com/web." -) - -COLORS = { - "red": "#FF0000", - "green": "#008000", - "yellow": "#FFA500", - "blue": "#0000FF", - "white": "#FFFFFF", - "cyan": "#00FFFF", -} # Slack doesn't know its colors - - -MARKDOWN_LINK_REGEX = re.compile( - r"(?[^\]]+?)\]\((?P[a-zA-Z0-9]+?:\S+?)\)" -) - - -def slack_markdown_converter(compact_output=False): - """ - This is a Markdown converter for use with Slack. - """ - enable_format("imtext", IMTEXT_CHRS, borders=not compact_output) - md = Markdown( - output_format="imtext", extensions=[ExtraExtension(), AnsiExtension()] - ) - md.preprocessors["LinkPreProcessor"] = LinkPreProcessor(md) - md.stripTopLevelTags = False - return md - - -class LinkPreProcessor(Preprocessor): - """ - This preprocessor converts markdown URL notation into Slack URL notation - as described at https://api.slack.com/docs/formatting, section "Linking to URLs". - """ - - def run(self, lines): - for i, line in enumerate(lines): - lines[i] = MARKDOWN_LINK_REGEX.sub(r"<\2|\1>", line) - return lines - - -class SlackAPIResponseError(RuntimeError): - """Slack API returned a non-OK response""" - - def __init__(self, *args, error="", **kwargs): - """ - :param error: - The 'error' key from the API response data - """ - self.error = error - super().__init__(*args, **kwargs) - - -class SlackPerson(Person): - """ - This class describes a person on Slack's network. - """ - - def __init__(self, webclient: WebClient, userid=None, channelid=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)" - ) - - if channelid is not None and channelid[0] not in ("D", "C", "G"): - raise Exception( - f"This is not a valid Slack channelid: {channelid} (should start with D, C or G)" - ) - - self._userid = userid - self._channelid = channelid - self._webclient = webclient - self._username = None # cache - self._fullname = None - self._channelname = None - self._email = None - - @property - def userid(self): - return self._userid - - @property - def username(self): - """Convert a Slack user ID to their user name""" - if self._username: - return self._username - - user = self._webclient.users_info(user=self._userid)["user"] - if user is None: - log.error("Cannot find user with ID %s", self._userid) - return f"<{self._userid}>" - - if not self._username: - self._username = user["name"] - return self._username - - @property - def channelid(self): - return self._channelid - - @property - def channelname(self): - """Convert a Slack channel ID to its channel name""" - if self._channelid is None: - return None - - if self._channelname: - return self._channelname - - channel = [ - channel - for channel in self._webclient.channels_list() - if channel["id"] == self._channelid - ][0] - if channel is None: - raise RoomDoesNotExistError(f"No channel with ID {self._channelid} exists.") - if not self._channelname: - self._channelname = channel["name"] - return self._channelname - - @property - def domain(self): - raise NotImplemented() - - # Compatibility with the generic API. - client = channelid - nick = username - - # Override for ACLs - @property - def aclattr(self): - # Note: Don't use str(self) here because that will return - # an incorrect format from SlackMUCOccupant. - return f"@{self.username}" - - @property - def fullname(self): - """Convert a Slack user ID to their full name""" - if self._fullname: - return self._fullname - - user = self._webclient.users_info(user=self._userid)["user"] - if user is None: - log.error("Cannot find user with ID %s", self._userid) - return f"<{self._userid}>" - - if not self._fullname: - self._fullname = user["real_name"] - - return self._fullname - - @property - def email(self): - """Convert a Slack user ID to their user email""" - user = self._webclient.users_info(user=self._userid)["user"] - if user is None: - log.error("Cannot find user with ID %s", self._userid) - return "<%s>" % self._userid - - email = user["profile"]["email"] - return email - - def __unicode__(self): - return f"@{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, SlackPerson): - log.warning("tried to compare a SlackPerson with a %s", type(other)) - return False - return other.userid == self.userid - - def __hash__(self): - return self.userid.__hash__() - - @property - def person(self): - # Don't use str(self) here because we want SlackRoomOccupant - # to return just our @username too. - return f"@{self.username}" - - -class SlackRoomOccupant(RoomOccupant, SlackPerson): - """ - This class represents a person inside a MUC. - """ - - def __init__(self, webclient: WebClient, userid, channelid, bot): - super().__init__(webclient, userid, channelid) - self._room = SlackRoom(webclient=webclient, channelid=channelid, bot=bot) - - @property - def room(self): - return self._room - - def __unicode__(self): - return f"#{self._room.name}/{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, SlackRoomOccupant): - log.warning( - "tried to compare a SlackRoomOccupant with a SlackPerson %s vs %s", - self, - other, - ) - return False - return other.room.id == self.room.id and other.userid == self.userid - - -class SlackBot(SlackPerson): - """ - This class describes a bot on Slack's network. - """ - - def __init__(self, webclient: WebClient, bot_id, bot_username): - self._bot_id = bot_id - self._bot_username = bot_username - super().__init__(webclient, userid=bot_id) - - @property - def username(self): - return self._bot_username - - # Beware of gotcha. Without this, nick would point to username of SlackPerson. - nick = username - - @property - def aclattr(self): - # Make ACLs match against integration ID rather than human-readable - # nicknames to avoid webhooks impersonating other people. - return f"<{self._bot_id}>" - - @property - def fullname(self): - return None - - -class SlackRoomBot(RoomOccupant, SlackBot): - """ - 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(webclient=sc, channelid=channelid, bot=bot) - - @property - def room(self): - return self._room - - def __unicode__(self): - return f"#{self._room.name}/{self.username}" - - def __str__(self): - return self.__unicode__() - - def __eq__(self, other): - if not isinstance(other, SlackRoomOccupant): - log.warning( - "tried to compare a SlackRoomBotOccupant with a SlackPerson %s vs %s", - self, - other, - ) - return False - return other.room.id == self.room.id and other.userid == self.userid - - -class SlackRTMBackend(ErrBot): - @staticmethod - def _unpickle_identifier(identifier_str): - return SlackRTMBackend.__build_identifier(identifier_str) - - @staticmethod - def _pickle_identifier(identifier): - return SlackRTMBackend._unpickle_identifier, (str(identifier),) - - def _register_identifiers_pickling(self): - """ - Register identifiers pickling. - - As Slack needs live objects in its identifiers, we need to override their pickling behavior. - 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. - """ - SlackRTMBackend.__build_identifier = self.build_identifier - for cls in (SlackPerson, SlackRoomOccupant, SlackRoom): - copyreg.pickle( - cls, - SlackRTMBackend._pickle_identifier, - SlackRTMBackend._unpickle_identifier, - ) - - def __init__(self, config): - super().__init__(config) - identity = config.BOT_IDENTITY - self.token = identity.get("token", None) - self.proxies = identity.get("proxies", 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.webclient = None - self.bot_identifier = None - compact = config.COMPACT_OUTPUT if hasattr(config, "COMPACT_OUTPUT") else False - self.md = slack_markdown_converter(compact) - self._register_identifiers_pickling() - - def update_alternate_prefixes(self): - """Converts BOT_ALT_PREFIXES to use the slack ID instead of name - - Slack only acknowledges direct callouts `@username` in chat if referred - by using the ID of that user. - """ - # convert BOT_ALT_PREFIXES to a list - try: - bot_prefixes = self.bot_config.BOT_ALT_PREFIXES.split(",") - except AttributeError: - bot_prefixes = list(self.bot_config.BOT_ALT_PREFIXES) - - 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, - ) - - 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) - - def _setup_slack_callbacks(self): - @RTMClient.run_on(event="message") - def serve_messages(**payload): - self._message_event_handler(payload["web_client"], payload["data"]) - - @RTMClient.run_on(event="member_joined_channel") - def serve_joins(**payload): - self._member_joined_channel_event_handler( - payload["web_client"], payload["data"] - ) - - @RTMClient.run_on(event="hello") - def serve_hellos(**payload): - self._hello_event_handler(payload["web_client"], payload["data"]) - - @RTMClient.run_on(event="presence_change") - def serve_presences(**payload): - self._presence_change_event_handler(payload["web_client"], payload["data"]) - - def serve_forever(self): - log.warning( - "This backend is deprecated and will be removed in a future release." - " No future updates, bug fixes or enhancements will be included." - " We strongly advise migrating to SlackV3, which is available" - " at https://github.com/errbotio/err-backend-slackv3." - ) - self.sc = RTMClient(token=self.token, proxy=self.proxies) - - @RTMClient.run_on(event="open") - def get_bot_identity(**payload): - self.bot_identifier = SlackPerson( - payload["web_client"], payload["data"]["self"]["id"] - ) - # only hook up the message callback once we have our identity set. - self._setup_slack_callbacks() - - # log.info('Verifying authentication token') - # 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") - - log.info("Connecting to Slack real-time-messaging API") - self.sc.start() - # Inject bot identity to alternative prefixes - self.update_alternate_prefixes() - - try: - while True: - sleep(1) - 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() - - def _hello_event_handler(self, webclient: WebClient, event): - """Event handler for the 'hello' event""" - self.webclient = webclient - self.connect_callback() - self.callback_presence(Presence(identifier=self.bot_identifier, status=ONLINE)) - - def _presence_change_event_handler(self, webclient: WebClient, event): - """Event handler for the 'presence_change' event""" - - idd = SlackPerson(webclient, 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( - f"It appears the Slack API changed, I received an unknown presence type {presence}." - ) - status = ONLINE - self.callback_presence(Presence(identifier=idd, status=status)) - - def _message_event_handler(self, webclient: WebClient, event): - """Event handler for the 'message' event""" - channel = event["channel"] - if channel[0] not in "CGD": - log.warning("Unknown message type! Unable to handle %s", 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) - 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 - text = event["text"] - - 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) - - msg = Message( - text, - extras={ - "attachments": event.get("attachments"), - "slack_event": event, - }, - ) - - if channel.startswith("D"): - if subtype == "bot_message": - msg.frm = SlackBot( - webclient, - bot_id=event.get("bot_id"), - bot_username=event.get("username", ""), - ) - else: - msg.frm = SlackPerson(webclient, event["user"], event["channel"]) - msg.to = SlackPerson( - webclient, self.bot_identifier.userid, event["channel"] - ) - channel_link_name = event["channel"] - else: - if subtype == "bot_message": - msg.frm = SlackRoomBot( - webclient, - bot_id=event.get("bot_id"), - bot_username=event.get("username", ""), - channelid=event["channel"], - bot=self, - ) - else: - msg.frm = SlackRoomOccupant( - webclient, event["user"], event["channel"], bot=self - ) - msg.to = SlackRoom( - webclient=webclient, channelid=event["channel"], bot=self - ) - channel_link_name = msg.to.name - - # TODO: port to slackclient2 - # msg.extras['url'] = f'https://{self.sc.server.domain}.slack.com/archives/' \ - # f'{channel_link_name}/p{self._ts_for_message(msg).replace(".", "")}' - - self.callback_message(msg) - - if mentioned: - self.callback_mention(msg, mentioned) - - def _member_joined_channel_event_handler(self, webclient: WebClient, event): - """Event handler for the 'member_joined_channel' event""" - user = SlackPerson(webclient, event["user"]) - if user == self.bot_identifier: - user = self.bot_identifier - self.callback_room_joined( - SlackRoom(webclient=webclient, channelid=event["channel"], bot=self), user - ) - - def userid_to_username(self, id_: str): - """Convert a Slack user ID to their user name""" - user = self.webclient.users_info(user=id_)["user"] - if user is None: - raise UserDoesNotExistError(f"Cannot find user with ID {id_}.") - return user["name"] - - def username_to_userid(self, name: str): - """Convert a Slack user name to their user ID""" - name = name.lstrip("@") - user = [ - user - for user in self.webclient.users_list()["members"] - if user["name"] == name - ] - if user == []: - raise UserDoesNotExistError(f"Cannot find user {name}.") - if len(user) > 1: - log.error( - "Failed to uniquely identify '{}'. Errbot found the following users: {}".format( - name, " ".join(["{}={}".format(u["name"], u["id"]) for u in user]) - ) - ) - raise UserNotUniqueError(f"Failed to uniquely identify {name}.") - return user[0]["id"] - - def channelid_to_channelname(self, id_: str): - """Convert a Slack channel ID to its channel name""" - channel = self.webclient.conversations_info(channel=id_)["channel"] - if channel is None: - raise RoomDoesNotExistError(f"No channel with ID {id_} exists.") - return channel["name"] - - def channelname_to_channelid(self, name: str): - """Convert a Slack channel name to its channel ID""" - name = name.lstrip("#") - channel = [ - channel - for channel in self.webclient.channels_list() - if channel.name == name - ] - if not channel: - raise RoomDoesNotExistError(f"No channel named {name} exists") - 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.webclient.channels_list(exclude_archived=exclude_archived) - channels = [ - channel - for channel in response["channels"] - if channel["is_member"] or not joined_only - ] - - response = self.webclient.groups_list(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(1024) - def get_im_channel(self, id_): - """Open a direct message channel to a user""" - try: - response = self.webclient.im_open(user=id_) - return response["channel"]["id"] - except SlackAPIResponseError as e: - if e.error == "cannot_dm_bot": - log.info("Tried to DM a bot.") - return None - else: - raise e - - def _prepare_message(self, msg): # or card - """ - Translates the common part of messaging for Slack. - :param msg: the message you want to extract the Slack concept from. - :return: a tuple to user human readable, the channel id - """ - if msg.is_group: - to_channel_id = msg.to.id - to_humanreadable = ( - msg.to.name - if msg.to.name - else self.channelid_to_channelname(to_channel_id) - ) - else: - to_humanreadable = msg.to.username - 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) - ) - return to_humanreadable, to_channel_id - - def send_message(self, msg): - super().send_message(msg) - - if msg.parent is not None: - # we are asked to reply to a specify thread. - try: - msg.extras["thread_ts"] = self._ts_for_message(msg.parent) - except KeyError: - # Gives to the user a more interesting explanation if we cannot find a ts from the parent. - log.exception( - "The provided parent message is not a Slack message " - "or does not contain a Slack timestamp." - ) - - to_humanreadable = "" - try: - if msg.is_group: - to_channel_id = msg.to.id - to_humanreadable = ( - msg.to.name - if msg.to.name - else self.channelid_to_channelname(to_channel_id) - ) - else: - 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) - ) - 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, - ) - body = self.md.convert(msg.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) - - timestamps = [] - for part in parts: - data = { - "channel": to_channel_id, - "text": part, - "unfurl_media": "true", - "link_names": "1", - "as_user": "true", - } - - # 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.webclient.chat_postMessage(**data) - timestamps.append(result["ts"]) - - msg.extras["ts"] = timestamps - except Exception: - log.exception( - f"An exception occurred while trying to send the following message " - f"to {to_humanreadable}: {msg.body}." - ) - - def _slack_upload(self, stream: Stream) -> None: - """ - Performs an upload defined in a stream - :param stream: Stream object - :return: None - """ - try: - stream.accept() - resp = self.webclient.files_upload( - channels=stream.identifier.channelid, filename=stream.name, file=stream - ) - if "ok" in resp and resp["ok"]: - stream.success() - else: - stream.error() - except Exception: - log.exception( - f"Upload of {stream.name} to {stream.identifier.channelname} failed." - ) - - def send_stream_request( - self, - user: Identifier, - fsource: BinaryIO, - name: str = None, - size: int = None, - stream_type: str = None, - ) -> Stream: - """ - Starts a file transfer. For Slack, the size and stream_type are unsupported - - :param user: is the identifier of the person you want to send it to. - :param fsource: is a file object you want to send. - :param name: is an optional filename for it. - :param size: not supported in Slack backend - :param stream_type: not supported in Slack backend - - :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, - ) - self.thread_pool.apply_async(self._slack_upload, (stream,)) - return stream - - def send_card(self, card: Card): - if isinstance(card.to, RoomOccupant): - card.to = card.to.room - to_humanreadable, to_channel_id = self._prepare_message(card) - attachment = {} - if card.summary: - attachment["pretext"] = card.summary - if card.title: - attachment["title"] = card.title - if card.link: - attachment["title_link"] = card.link - if card.image: - attachment["image_url"] = card.image - if card.thumbnail: - attachment["thumb_url"] = card.thumbnail - - if card.color: - attachment["color"] = ( - COLORS[card.color] if card.color in COLORS else card.color - ) - - if card.fields: - attachment["fields"] = [ - {"title": key, "value": value, "short": True} - for key, value in card.fields - ] - - limit = min(self.bot_config.MESSAGE_SIZE_LIMIT, SLACK_MESSAGE_LIMIT) - parts = self.prepare_message_body(card.body, limit) - part_count = len(parts) - footer = attachment.get("footer", "") - for i in range(part_count): - if part_count > 1: - attachment["footer"] = f"{footer} [{i + 1}/{part_count}]" - attachment["text"] = parts[i] - data = { - "channel": to_channel_id, - "attachments": json.dumps([attachment]), - "link_names": "1", - "as_user": "true", - } - try: - log.debug("Sending data:\n%s", data) - self.webclient.chat_postMessage(**data) - except Exception: - log.exception( - f"An exception occurred while trying to send a card to {to_humanreadable}.[{card}]" - ) - - def __hash__(self): - return 0 # this is a singleton anyway - - def change_presence(self, status: str = ONLINE, message: str = "") -> None: - self.webclient.users_setPresence( - 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> - <@U12345|user> - @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>`, " - "`<@U12345|user>`, `@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, D or W (got `%s`)" - ) - text = text[2:-1] - if text == "": - raise ValueError(exception_message % "") - if text[0] in ("U", "B", "W"): - if "|" in text: - userid, username = text.split("|") - else: - 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 None and username is not None: - userid = self.username_to_userid(username) - 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.webclient, userid, channelid, bot=self) - if userid is not None: - return SlackPerson(self.webclient, userid, self.get_im_channel(userid)) - if channelid is not None: - return SlackRoom(webclient=self.webclient, channelid=channelid, bot=self) - - 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 is_from_self(self, msg: Message) -> bool: - return self.bot_identifier.userid == msg.frm.userid - - def build_reply(self, msg, text=None, private=False, threaded=False): - response = self.build_message(text) - - if "thread_ts" in msg.extras["slack_event"]: - # If we reply to a threaded message, keep it in the thread. - response.extras["thread_ts"] = msg.extras["slack_event"]["thread_ts"] - elif threaded: - # otherwise check if we should start a new thread - response.parent = msg - - response.frm = self.bot_identifier - if private: - response.to = msg.frm - else: - response.to = msg.frm.room if isinstance(msg.frm, RoomOccupant) else msg.frm - return response - - def add_reaction(self, msg: Message, reaction: str) -> None: - """ - Add the specified reaction to the Message if you haven't already. - :param msg: A Message. - :param reaction: A str giving an emoji, without colons before and after. - :raises: ValueError if the emoji doesn't exist. - """ - return self._react("reactions.add", msg, reaction) - - def remove_reaction(self, msg: Message, reaction: str) -> None: - """ - Remove the specified reaction from the Message if it is currently there. - :param msg: A Message. - :param reaction: A str giving an emoji, without colons before and after. - :raises: ValueError if the emoji doesn't exist. - """ - return self._react("reactions.remove", msg, reaction) - - def _react(self, method: str, msg: Message, reaction: str) -> None: - try: - # this logic is from send_message - if msg.is_group: - to_channel_id = msg.to.id - else: - to_channel_id = msg.to.channelid - - ts = self._ts_for_message(msg) - - self.api_call( - method, - data={"channel": to_channel_id, "timestamp": ts, "name": reaction}, - ) - except SlackAPIResponseError as e: - if e.error == "invalid_name": - raise ValueError(e.error, "No such emoji", reaction) - elif e.error in ("no_reaction", "already_reacted"): - # This is common if a message was edited after you reacted to it, and you reacted to it again. - # Chances are you don't care about this. If you do, call api_call() directly. - pass - else: - raise SlackAPIResponseError(error=e.error) - - def _ts_for_message(self, msg): - try: - return msg.extras["slack_event"]["message"]["ts"] - except KeyError: - return msg.extras["slack_event"]["ts"] - - def shutdown(self): - super().shutdown() - - @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(webclient=self.webclient, channelid=room, bot=self) - - m = SLACK_CLIENT_CHANNEL_HYPERLINK.match(room) - if m is not None: - return SlackRoom( - webclient=self.webclient, channelid=m.groupdict()["id"], bot=self - ) - - return SlackRoom(webclient=self.webclient, 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(webclient=self.webclient, channelid=channel["id"], bot=self) - for channel in channels - ] - - def prefix_groupchat_reply(self, message, identifier): - super().prefix_groupchat_reply(message, identifier) - message.body = f"@{identifier.nick}: {message.body}" - - @staticmethod - def sanitize_uris(text): - """ - Sanitizes URI's present within a slack message. e.g. - , - - - - :returns: - string - """ - text = re.sub(r"<([^|>]+)\|([^|>]+)>", r"\2", text) - text = re.sub(r"<(http([^>]+))>", r"\1", text) - - return text - - def process_mentions(self, text): - """ - Process mentions in a given string - :returns: - A formatted string of the original message - and a list of :class:`~SlackPerson` instances. - """ - mentioned = [] - - 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, - ) - continue - - # We only track mentions of persons. - if isinstance(identifier, SlackPerson): - log.debug("Someone mentioned") - mentioned.append(identifier) - text = text.replace(word, str(identifier)) - - return text, mentioned - - -class SlackRoom(Room): - def __init__(self, webclient=None, 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 - else: - self._name = bot.channelid_to_channelname(channelid) - - self._id = None - self._bot = bot - self.webclient = webclient - - def __str__(self): - return f"#{self.name}" - - @property - def channelname(self): - return self._name - - @property - def _channel(self): - """ - The channel object exposed by SlackClient - """ - _id = None - for channel in self.webclient.conversations_list()["channels"]: - if channel["name"] == self.name: - _id = channel["id"] - break - else: - raise RoomDoesNotExistError( - f"{str(self)} does not exist (or is a private group you don't have access to)" - ) - return _id - - @property - def _channel_info(self): - """ - Channel info as returned by the Slack API. - - See also: - * https://api.slack.com/methods/channels.list - * https://api.slack.com/methods/groups.list - """ - if self.private: - return self._bot.webclient.conversations_info(channel=self.id)["group"] - else: - return self._bot.webclient.conversations_info(channel=self.id)["channel"] - - @property - def private(self): - """Return True if the room is a private group""" - return self._channel.id.startswith("G") - - @property - def id(self): - """Return the ID of this room""" - if self._id is None: - self._id = self._channel - return self._id - - @property - def name(self): - """Return the name of this room""" - return self._name - - def join(self, username=None, password=None): - log.info("Joining channel %s", str(self)) - try: - self._bot.webclient.channels_join(name=self.name) - except BotUserAccessError as e: - raise RoomError(f"Unable to join channel. {USER_IS_BOT_HELPTEXT}") - - def leave(self, reason=None): - try: - if self.id.startswith("C"): - log.info("Leaving channel %s (%s)", self, self.id) - self._bot.webclient.channels_leave(channel=self.id) - else: - log.info("Leaving group %s (%s)", self, self.id) - self._bot.webclient.groups_leave(channel=self.id) - except SlackAPIResponseError as e: - if e.error == "user_is_bot": - raise RoomError(f"Unable to leave channel. {USER_IS_BOT_HELPTEXT}") - else: - raise RoomError(e) - self._id = None - - def create(self, private=False): - try: - if private: - log.info("Creating group %s.", self) - self._bot.webclient.groups_create(name=self.name) - else: - log.info("Creating channel %s.", self) - self._bot.webclient.channels_create(name=self.name) - except SlackAPIResponseError as e: - if e.error == "user_is_bot": - raise RoomError(f"Unable to create channel. {USER_IS_BOT_HELPTEXT}") - else: - raise RoomError(e) - - def destroy(self): - try: - if self.id.startswith("C"): - log.info("Archiving channel %s (%s)", self, self.id) - self._bot.api_call("channels.archive", data={"channel": self.id}) - else: - log.info("Archiving group %s (%s)", self, self.id) - self._bot.api_call("groups.archive", data={"channel": self.id}) - 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) - self._id = None - - @property - def exists(self): - channels = self._bot.channels(joined_only=False, exclude_archived=False) - return len([c for c in channels if c["name"] == self.name]) > 0 - - @property - def joined(self): - channels = self._bot.channels(joined_only=True) - return len([c for c in channels if c["name"] == self.name]) > 0 - - @property - def topic(self): - if self._channel_info["topic"]["value"] == "": - return None - else: - return self._channel_info["topic"]["value"] - - @topic.setter - def topic(self, topic): - if self.private: - log.info("Setting topic of %s (%s) to %s.", self, self.id, topic) - self._bot.api_call( - "groups.setTopic", data={"channel": self.id, "topic": topic} - ) - else: - log.info("Setting topic of %s (%s) to %s.", self, self.id, topic) - self._bot.api_call( - "channels.setTopic", data={"channel": self.id, "topic": topic} - ) - - @property - def purpose(self): - if self._channel_info["purpose"]["value"] == "": - return None - else: - return self._channel_info["purpose"]["value"] - - @purpose.setter - def purpose(self, purpose): - if self.private: - log.info("Setting purpose of %s (%s) to %s.", self, self.id, purpose) - self._bot.api_call( - "groups.setPurpose", data={"channel": self.id, "purpose": purpose} - ) - else: - log.info("Setting purpose of %s (%s) to %s.", str(self), self.id, purpose) - self._bot.api_call( - "channels.setPurpose", data={"channel": self.id, "purpose": purpose} - ) - - @property - def occupants(self): - members = self._channel_info["members"] - return [SlackRoomOccupant(self.sc, 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 = "groups.invite" if self.private else "channels.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": - 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"]}.' - ) - - def __eq__(self, other): - if not isinstance(other, SlackRoom): - return False - return self.id == other.id diff --git a/errbot/cli.py b/errbot/cli.py index b0f8585ae..7908ae635 100755 --- a/errbot/cli.py +++ b/errbot/cli.py @@ -209,6 +209,7 @@ def main() -> None: data_dir = base_dir / "data" extra_plugin_dir = base_dir / "plugins" + extra_backend_plugin_dir = base_dir / "backend-plugins" example_plugin_dir = extra_plugin_dir / "err-example" log_path = base_dir / "errbot.log" @@ -220,6 +221,7 @@ def main() -> None: data_dir.mkdir(exist_ok=True) extra_plugin_dir.mkdir(exist_ok=True) + extra_backend_plugin_dir.mkdir(exist_ok=True) example_plugin_dir.mkdir(exist_ok=True) with open(base_dir / "config.py", "w") as f: @@ -227,6 +229,7 @@ def main() -> None: config_template.render( data_dir=str(data_dir), extra_plugin_dir=str(extra_plugin_dir), + extra_backend_plugin_dir=str(extra_backend_plugin_dir), log_path=str(log_path), ) ) diff --git a/errbot/templates/initdir/config.py.tmpl b/errbot/templates/initdir/config.py.tmpl index 4f574d52d..fed0265d6 100644 --- a/errbot/templates/initdir/config.py.tmpl +++ b/errbot/templates/initdir/config.py.tmpl @@ -9,6 +9,7 @@ BACKEND = "Text" # Errbot will start in text mode (console only mode) and will BOT_DATA_DIR = r"{{ data_dir }}" BOT_EXTRA_PLUGIN_DIR = r"{{ extra_plugin_dir }}" +BOT_EXTRA_BACKEND_DIR = r"{{ extra_backend_plugin_dir }}" BOT_LOG_FILE = r"{{ log_path }}" BOT_LOG_LEVEL = logging.INFO diff --git a/setup.py b/setup.py index 071f15631..2c3055ff8 100755 --- a/setup.py +++ b/setup.py @@ -117,12 +117,6 @@ def read(fname, encoding="ascii"): "IRC": [ "irc==20.0.0", ], - "slack": [ - "slackclient==1.3.2", - ], - "slack-rtm": [ - "slackclient==2.9.3", - ], "telegram": [ "python-telegram-bot==13.10", ], diff --git a/tests/backend_tests/slack_test.py b/tests/backend_tests/slack_test.py deleted file mode 100644 index 3d9473032..000000000 --- a/tests/backend_tests/slack_test.py +++ /dev/null @@ -1,304 +0,0 @@ -import logging -import os -import sys -import unittest -from tempfile import mkdtemp - -from mock import MagicMock - -from errbot.bootstrap import bot_config_defaults - -log = logging.getLogger(__name__) - -try: - from errbot.backends import slack - - class TestSlackBackend(slack.SlackBackend): - __test__ = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.test_msgs = [] - self.sc = MagicMock() - - def callback_message(self, msg): - self.test_msgs.append(msg) - - def username_to_userid(self, username, *args, **kwargs): - """Have to mock because we don't have a slack server.""" - return "Utest" - - def channelname_to_channelid(self, channelname): - return "Ctest" - - def channelid_to_channelname(self, channelid): - return "meh" - - def get_im_channel(self, id_): - return "Cfoo" - - def find_user(self, user): - m = MagicMock() - m.name = user - return m - -except SystemExit: - log.exception("Can't import backends.slack for testing") - - -@unittest.skipIf(not slack, "package slackclient not installed") -class SlackTests(unittest.TestCase): - def setUp(self): - # make up a config. - tempdir = mkdtemp() - # reset the config every time - sys.modules.pop("errbot.config-template", None) - __import__("errbot.config-template") - config = sys.modules["errbot.config-template"] - bot_config_defaults(config) - config.BOT_DATA_DIR = tempdir - config.BOT_LOG_FILE = os.path.join(tempdir, "log.txt") - config.BOT_EXTRA_PLUGIN_DIR = [] - config.BOT_LOG_LEVEL = logging.DEBUG - config.BOT_IDENTITY = {"username": "err@localhost", "token": "___"} - config.BOT_ASYNC = False - config.BOT_PREFIX = "!" - config.CHATROOM_FN = "blah" - - self.slack = TestSlackBackend(config) - - def testBotMessageWithAttachments(self): - attachment = { - "title": "sometitle", - "id": 1, - "fallback": " *Host:* host-01", - "color": "daa038", - "fields": [{"title": "Metric", "value": "1", "short": True}], - "title_link": "https://xx.com", - } - bot_id = "B04HMXXXX" - bot_msg = { - "channel": "C0XXXXY6P", - "icons": {"emoji": ":warning:", "image_64": "https://xx.com/26a0.png"}, - "ts": "1444416645.000641", - "type": "message", - "text": "", - "bot_id": bot_id, - "username": "riemann", - "subtype": "bot_message", - "attachments": [attachment], - } - - self.slack._dispatch_slack_message(bot_msg) - msg = self.slack.test_msgs.pop() - - self.assertEqual(msg.extras["attachments"], [attachment]) - - def testSlackEventObjectAddedToExtras(self): - bot_id = "B04HMXXXX" - bot_msg = { - "channel": "C0XXXXY6P", - "icons": {"emoji": ":warning:", "image_64": "https://xx.com/26a0.png"}, - "ts": "1444416645.000641", - "type": "message", - "text": "", - "bot_id": bot_id, - "username": "riemann", - "subtype": "bot_message", - } - - self.slack._dispatch_slack_message(bot_msg) - msg = self.slack.test_msgs.pop() - - self.assertEqual(msg.extras["slack_event"], bot_msg) - - def testPrepareMessageBody(self): - test_body = """ - hey, this is some code: - ``` - foobar - ``` - """ - parts = self.slack.prepare_message_body(test_body, 10000) - assert parts == [test_body] - - test_body = """this block is unclosed: ``` foobar """ - parts = self.slack.prepare_message_body(test_body, 10000) - assert parts == [test_body + "\n```\n"] - - test_body = """``` foobar """ - parts = self.slack.prepare_message_body(test_body, 10000) - assert parts == [test_body + "\n```\n"] - - test_body = """closed ``` foobar ``` not closed ```""" - # ---------------------------------^ 21st char - parts = self.slack.prepare_message_body(test_body, 21) - assert len(parts) == 2 - assert parts[0].count("```") == 2 - assert parts[0].endswith("```") - assert parts[1].count("```") == 2 - assert parts[1].endswith("```\n") - - def test_extract_identifiers(self): - extract_from = self.slack.extract_identifiers_from_string - - self.assertEqual(extract_from("<@U12345>"), (None, "U12345", None, None)) - - self.assertEqual( - extract_from("<@U12345|UName>"), ("UName", "U12345", None, None) - ) - - self.assertEqual(extract_from("<@B12345>"), (None, "B12345", None, None)) - - self.assertEqual(extract_from("<#C12345>"), (None, None, None, "C12345")) - - self.assertEqual(extract_from("<#G12345>"), (None, None, None, "G12345")) - - self.assertEqual(extract_from("<#D12345>"), (None, None, None, "D12345")) - - self.assertEqual(extract_from("@person"), ("person", None, None, None)) - - self.assertEqual( - extract_from("#general/someuser"), ("someuser", None, "general", None) - ) - - self.assertEqual(extract_from("#general"), (None, None, "general", None)) - - with self.assertRaises(ValueError): - extract_from("") - - with self.assertRaises(ValueError): - extract_from("general") - - with self.assertRaises(ValueError): - extract_from("<>") - - with self.assertRaises(ValueError): - extract_from("") - - with self.assertRaises(ValueError): - extract_from("<@I12345>") - - def test_build_identifier(self): - build_from = self.slack.build_identifier - - def check_person(person, expected_uid, expected_cid): - return person.userid == expected_uid and person.channelid == expected_cid - - assert build_from("<#C12345>").name == "meh" - assert check_person(build_from("<@U12345>"), "U12345", "Cfoo") - assert check_person(build_from("@user"), "Utest", "Cfoo") - assert build_from("#channel").name == "meh" # the mock always return meh ;) - - self.assertEqual( - build_from("#channel/user"), - slack.SlackRoomOccupant(None, "Utest", "Cfoo", self.slack), - ) - - def test_uri_sanitization(self): - sanitize = self.slack.sanitize_uris - - self.assertEqual( - sanitize("The email is ."), - "The email is test@example.org.", - ) - - self.assertEqual( - sanitize( - "Pretty URL Testing: with " "more text" - ), - "Pretty URL Testing: example.org with more text", - ) - - self.assertEqual(sanitize("URL "), "URL http://example.org") - - self.assertEqual( - sanitize("Normal <text> that shouldn't be affected"), - "Normal <text> that shouldn't be affected", - ) - - self.assertEqual( - sanitize( - "Multiple uris , " - " and " - ", and " - "." - ), - "Multiple uris test@example.org, other@example.org and " - "http://www.example.org, https://example.com and subdomain.example.org.", - ) - - def test_slack_markdown_link_preprocessor(self): - convert = self.slack.md.convert - self.assertEqual( - "This is .", - convert("This is [a link](http://example.com/)."), - ) - self.assertEqual( - "This is and .", - convert( - "This is [a link](https://example.com/) and [an email address](mailto:me@comp.org)." - ), - ) - self.assertEqual( - "This is and a manual URL: https://example.com/.", - convert( - "This is [a link](http://example.com/) and a manual URL: https://example.com/." - ), - ) - self.assertEqual( - "", - convert("[This is a link](http://example.com/)"), - ) - self.assertEqual( - "This is http://example.com/image.png.", - convert("This is ![an image](http://example.com/image.png)."), - ) - self.assertEqual( - "This is [some text] then ", - convert("This is [some text] then [a link](http://example.com)"), - ) - - def test_mention_processing(self): - self.slack.sc.server.users.find = MagicMock(side_effect=self.slack.find_user) - - mentions = self.slack.process_mentions - - self.assertEqual( - mentions("<@U1><@U2><@U3>"), - ( - "@U1@U2@U3", - [ - self.slack.build_identifier("<@U1>"), - self.slack.build_identifier("<@U2>"), - self.slack.build_identifier("<@U3>"), - ], - ), - ) - - self.assertEqual( - mentions("Is <@U12345>: here?"), - ("Is @U12345: here?", [self.slack.build_identifier("<@U12345>")]), - ) - - self.assertEqual( - mentions("<@U12345> told me about @a and <@U56789> told me about @b"), - ( - "@U12345 told me about @a and @U56789 told me about @b", - [ - self.slack.build_identifier("<@U12345>"), - self.slack.build_identifier("<@U56789>"), - ], - ), - ) - - self.assertEqual( - mentions("!these!<@UABCDE>!mentions! will !still!<@UFGHIJ>!work!"), - ( - "!these!@UABCDE!mentions! will !still!@UFGHIJ!work!", - [ - self.slack.build_identifier("<@UABCDE>"), - self.slack.build_identifier("<@UFGHIJ>"), - ], - ), - )