diff --git a/zulip/integrations/bridge_with_slack/README.md b/zulip/integrations/bridge_with_slack/README.md index 209c64446..c1698bc5b 100644 --- a/zulip/integrations/bridge_with_slack/README.md +++ b/zulip/integrations/bridge_with_slack/README.md @@ -1,33 +1,51 @@ # Slack <--> Zulip bridge -This is a bridge between Slack and Zulip. +This integration is a bridge with Slack, delivering messages from +Zulip into Slack. It is designed for bidirectional bridging, with the +[Slack integration](https://zulip.com/integrations/doc/slack) used to +deliver messages from Slack into Zulip. + +Note that using these integrations together for bidirectional bridging +requires the updated version of the Slack integration included in +Zulip 9.4+. ## Usage ### 1. Zulip endpoint -1. Create a generic Zulip bot, with a full name like `Slack Bot`. -2. (Important) Subscribe the bot user to the Zulip stream you'd like to bridge your Slack - channel into. -3. In the `zulip` section of the configuration file, enter the bot's `zuliprc` - details (`email`, `api_key`, and `site`). -4. In the same section, also enter the Zulip `stream` and `topic`. + +1. Create a generic Zulip bot, with a full name like `Slack Bridge`. + +2. [Subscribe](https://zulip.com/help/manage-user-channel-subscriptions#subscribe-a-user-to-a-channel) + the bot user to the Zulip channel(s) you'd like to bridge with + Slack. + +3. Create a [Slack webhook integration bot](https://zulip.com/integrations/doc/slack) + to get messages from Slack to Zulip. Make sure to follow the additional instruction + for setting up a Slack bridge. + +4. In the `zulip` section of the `bridge_with_slack_config.py` + configuration file, the bot's `zuliprc` details (`email`, + `api_key`, and `site`). + +5. In the `channel_mapping` section, enter the Zulip `channel` and + `topic` that you'd like to use for each Slack channel. Make sure + that they match the same `channel` and `topic` you configured in + steps 2 and 3. ### 2. Slack endpoint -1. Make sure Websocket isn't blocked in the computer where you run this bridge. - Test it at https://www.websocket.org/echo.html. -2. Go to https://api.slack.com/apps?new_classic_app=1 and create a new classic - app (note: must be a classic app). Choose a bot name that will be put into - bridge_with_slack_config.py, e.g. "zulip_mirror". In the process of doing - this, you need to add oauth token scope. Simply choose `bot`. Slack will say - that this is a legacy scope, but we still need to use it anyway. The reason - why we need the legacy scope is because otherwise the RTM API wouldn't work. - We might remove the RTM API usage in newer version of this bot. Make sure to - install the app to the workspace. When successful, you should see a token - that starts with "xoxb-...". There is also a token that starts with - "xoxp-...", we need the "xoxb-..." one. -3. Go to "App Home", click the button "Add Legacy Bot User". -4. (Important) Make sure the bot is subscribed to the channel. You can do this by typing e.g. `/invite @zulip_mirror` in the relevant channel. -5. In the `slack` section of the Zulip-Slack bridge configuration file, enter the bot name (e.g. "zulip_mirror") and token, and the channel ID (note: must be ID, not name). + +1. Go to the [Slack Apps menu](https://api.slack.com/apps) and open the same Slack app + that you used to set up the Slack Webhook integration previously. + +2. Navigate to the "OAuth & Permissions" menu and scroll down to the "Scopes" + section in the same page. Make sure "Bot Token Scopes" includes: `chat:write` + +3. Next, also in the same menu find and note down the "Bot User OAuth Token". + It starts with "xoxb-..." and not "xoxp". + +4. In the `slack` section of `bridge_with_slack_config.py`, enter the + bot name (e.g "slack_bridge"), token (e.g xoxb-...), and the + channel ID (note: must be ID, not name). ### Running the bridge diff --git a/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py b/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py index 9dd733313..f1a89d82f 100644 --- a/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py +++ b/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py @@ -13,8 +13,8 @@ "channel_mapping": { # Slack channel; must be channel ID "C5Z5N7R8A": { - # Zulip stream - "stream": "test here", + # Zulip channel + "channel": "test here", # Zulip topic "topic": "<- slack-bridge", }, diff --git a/zulip/integrations/bridge_with_slack/run-slack-bridge b/zulip/integrations/bridge_with_slack/run-slack-bridge index 32c6deb1b..225f1af41 100755 --- a/zulip/integrations/bridge_with_slack/run-slack-bridge +++ b/zulip/integrations/bridge_with_slack/run-slack-bridge @@ -8,13 +8,11 @@ import traceback from typing import Any, Callable, Dict, Optional, Tuple import bridge_with_slack_config -import slack_sdk -from slack_sdk.rtm_v2 import RTMClient +from slack_sdk.web.client import WebClient import zulip # change these templates to change the format of displayed message -ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" SLACK_MESSAGE_TEMPLATE = "<{username}> {message}" StreamTopicT = Tuple[str, str] @@ -41,15 +39,26 @@ def get_slack_channel_for_zulip_message( return zulip_to_slack_map[stream_topic] +def check_token_access(token: str) -> None: + if token.startswith("xoxp-"): + print( + "--- Warning! ---\n" + "You entered a Slack user token, please copy the token under\n" + "'Bot User OAuth Token' which starts with 'xoxb-...'." + ) + sys.exit(1) + elif token.startswith("xoxb-"): + return + + class SlackBridge: def __init__(self, config: Dict[str, Any]) -> None: self.config = config self.zulip_config = config["zulip"] self.slack_config = config["slack"] - self.slack_to_zulip_map: Dict[str, Dict[str, str]] = config["channel_mapping"] self.zulip_to_slack_map: Dict[StreamTopicT, str] = { - (z["stream"], z["topic"]): s for s, z in config["channel_mapping"].items() + (z["channel"], z["topic"]): s for s, z in config["channel_mapping"].items() } # zulip-specific @@ -65,11 +74,9 @@ class SlackBridge: # https://github.com/zulip/python-zulip-api/issues/761 is fixed. self.zulip_client_constructor = zulip_client_constructor - # slack-specific - self.slack_client = rtm # Spawn a non-websocket client for getting the users # list and for posting messages in Slack. - self.slack_webclient = slack_sdk.WebClient(token=self.slack_config["token"]) + self.slack_webclient = WebClient(token=self.slack_config["token"]) def wrap_slack_mention_with_bracket(self, zulip_msg: Dict[str, Any]) -> None: words = zulip_msg["content"].split(" ") @@ -77,19 +84,18 @@ class SlackBridge: if w.startswith("@"): zulip_msg["content"] = zulip_msg["content"].replace(w, "<" + w + ">") - def replace_slack_id_with_name(self, msg: Dict[str, Any]) -> None: - words = msg["text"].split(" ") - for w in words: - if w.startswith("<@") and w.endswith(">"): - _id = w[2:-1] - msg["text"] = msg["text"].replace(_id, self.slack_id_to_name[_id]) + def is_message_from_slack(self, msg: Dict[str, Any]) -> bool: + # Check whether or not this message is from Slack to prevent + # them from being tossed back to Zulip. + return msg["sender_email"] == self.zulip_config.get("email") def zulip_to_slack(self) -> Callable[[Dict[str, Any]], None]: def _zulip_to_slack(msg: Dict[str, Any]) -> None: slack_channel = get_slack_channel_for_zulip_message( msg, self.zulip_to_slack_map, self.zulip_config["email"] ) - if slack_channel is not None: + + if slack_channel is not None and not self.is_message_from_slack(msg): self.wrap_slack_mention_with_bracket(msg) slack_text = SLACK_MESSAGE_TEMPLATE.format( username=msg["sender_full_name"], message=msg["content"] @@ -101,36 +107,6 @@ class SlackBridge: return _zulip_to_slack - def run_slack_listener(self) -> None: - members = self.slack_webclient.users_list()["members"] - # See also https://api.slack.com/changelog/2017-09-the-one-about-usernames - self.slack_id_to_name: Dict[str, str] = { - u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members - } - self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()} - - @rtm.on("message") - def slack_to_zulip(client: RTMClient, event: Dict[str, Any]) -> None: - if event["channel"] not in self.slack_to_zulip_map: - return - user_id = event["user"] - user = self.slack_id_to_name[user_id] - from_bot = user == self.slack_config["username"] - if from_bot: - return - self.replace_slack_id_with_name(event) - content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=event["text"]) - zulip_endpoint = self.slack_to_zulip_map[event["channel"]] - msg_data = dict( - type="stream", - to=zulip_endpoint["stream"], - subject=zulip_endpoint["topic"], - content=content, - ) - self.zulip_client_constructor().send_message(msg_data) - - self.slack_client.start() - if __name__ == "__main__": usage = """run-slack-bridge @@ -142,6 +118,8 @@ if __name__ == "__main__": sys.path.append(os.path.join(os.path.dirname(__file__), "..")) parser = argparse.ArgumentParser(usage=usage) + args = parser.parse_args() + config: Dict[str, Any] = bridge_with_slack_config.config if "channel_mapping" not in config: print( @@ -150,12 +128,11 @@ if __name__ == "__main__": ) sys.exit(1) + check_token_access(config["slack"]["token"]) + print("Starting slack mirroring bot") print("MAKE SURE THE BOT IS SUBSCRIBED TO THE RELEVANT ZULIP STREAM(S) & SLACK CHANNEL(S)!") - # We have to define rtm outside of SlackBridge because the rtm variable is used as a method decorator. - rtm = RTMClient(token=config["slack"]["token"]) - backoff = zulip.RandomExponentialBackoff(timeout_success_equivalent=300) while backoff.keep_going(): try: @@ -164,14 +141,14 @@ if __name__ == "__main__": zp = threading.Thread( target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),) ) - sp = threading.Thread(target=sb.run_slack_listener, args=()) print("Starting message handler on Zulip client") zp.start() - print("Starting message handler on Slack client") - sp.start() + print( + "Make sure your Slack Webhook integration is running\n" + "to receive messages from Slack." + ) zp.join() - sp.join() except Exception: traceback.print_exc() backoff.fail()