diff --git a/zulip/integrations/bridge_with_slack/README.md b/zulip/integrations/bridge_with_slack/README.md new file mode 100644 index 0000000..3ce1568 --- /dev/null +++ b/zulip/integrations/bridge_with_slack/README.md @@ -0,0 +1,24 @@ +# Slack <--> Zulip bridge + +This is a bridge between Slack and Zulip. + +## 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`. + +### 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". 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. (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. +4. In the `slack` section of the configuration file, enter the bot name (e.g. "zulip_mirror") and token, and the channel ID (note: must be ID, not name). + +### Running the bridge + +Run `python3 run-slack-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 new file mode 100644 index 0000000..ce41786 --- /dev/null +++ b/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py @@ -0,0 +1,14 @@ +config = { + "zulip": { + "email": "zulip-bot@email.com", + "api_key": "put api key here", + "site": "https://chat.zulip.org", + "stream": "test here", + "topic": "<- slack-bridge", + }, + "slack": { + "username": "slack username", + "token": "slack token", + "channel": "C5Z5N7R8A -- must be channel id", + } +} diff --git a/zulip/integrations/bridge_with_slack/requirements.txt b/zulip/integrations/bridge_with_slack/requirements.txt new file mode 100644 index 0000000..6f73f85 --- /dev/null +++ b/zulip/integrations/bridge_with_slack/requirements.txt @@ -0,0 +1 @@ +slackclient==2.0.0 diff --git a/zulip/integrations/bridge_with_slack/run-slack-bridge b/zulip/integrations/bridge_with_slack/run-slack-bridge new file mode 100755 index 0000000..4a62baa --- /dev/null +++ b/zulip/integrations/bridge_with_slack/run-slack-bridge @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import os +import argparse +import traceback +import multiprocessing as mp +import zulip +import slack +from typing import Any, Dict, Callable + +import bridge_with_slack_config + +# change these templates to change the format of displayed message +ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" +SLACK_MESSAGE_TEMPLATE = "<{username}> {message}" + +def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool: + is_a_stream = msg["type"] == "stream" + in_the_specified_stream = msg["display_recipient"] == config["stream"] + at_the_specified_subject = msg["subject"] == config["topic"] + + # We do this to identify the messages generated from Matrix -> Zulip + # and we make sure we don't forward it again to the Matrix. + not_from_zulip_bot = msg["sender_email"] != config["email"] + if is_a_stream and not_from_zulip_bot and in_the_specified_stream and at_the_specified_subject: + return True + return False + +class SlackBridge: + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + self.zulip_config = config["zulip"] + self.slack_config = config["slack"] + + # zulip-specific + self.zulip_client = zulip.Client( + email=self.zulip_config["email"], + api_key=self.zulip_config["api_key"], + site=self.zulip_config["site"]) + self.zulip_stream = self.zulip_config["stream"] + self.zulip_subject = self.zulip_config["topic"] + + # slack-specific + self.channel = self.slack_config["channel"] + self.slack_client = slack.RTMClient(token=self.slack_config["token"], auto_reconnect=True) + + def wrap_slack_mention_with_bracket(self, zulip_msg: Dict[str, Any]) -> None: + words = zulip_msg["content"].split(' ') + for w in words: + 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 zulip_to_slack(self) -> Callable[[Dict[str, Any]], None]: + def _zulip_to_slack(msg: Dict[str, Any]) -> None: + message_valid = check_zulip_message_validity(msg, self.zulip_config) + if message_valid: + self.wrap_slack_mention_with_bracket(msg) + slack_text = SLACK_MESSAGE_TEMPLATE.format(username=msg["sender_full_name"], + message=msg["content"]) + self.slack_client.rtm_send_message( + self.channel, + slack_text, + ) + return _zulip_to_slack + + def run_slack_listener(self) -> None: + # spawn a non-websocket client for getting the users list + _wc = slack.WebClient(token=self.slack_config["token"]) + members = _wc.users_list() + # See also https://api.slack.com/changelog/2017-09-the-one-about-usernames + self.slack_id_to_name = {u["id"]: u["profile"]["display_name"] for u in members} + self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()} + + @slack.RTMClient.run_on(event='message') + def slack_to_zulip(**payload: Any) -> None: + msg = payload['data'] + if msg['channel'] != self.channel: + return + user_id = msg['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(msg) + content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text']) + msg_data = dict( + type="stream", + to=self.zulip_stream, + subject=self.zulip_subject, + content=content) + self.zulip_client.send_message(msg_data) + + self.slack_client.start() + +if __name__ == "__main__": + usage = """run-slack-bridge + + Relay each message received at a specified subject in a specified stream from + the first realm to a channel in a Slack workspace. + """ + + sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + parser = argparse.ArgumentParser(usage=usage) + + print("Starting slack mirroring bot") + print("MAKE SURE THE BOT IS SUBSCRIBED TO THE RELEVANT ZULIP STREAM") + + config = bridge_with_slack_config.config + + backoff = zulip.RandomExponentialBackoff(timeout_success_equivalent=300) + while backoff.keep_going(): + try: + sb = SlackBridge(config) + + zp = mp.Process(target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),)) + sp = mp.Process(target=sb.run_slack_listener, args=()) + print("Starting message handler on Zulip client") + zp.start() + print("Starting message handler on Slack client") + sp.start() + + zp.join() + sp.join() + except Exception: + traceback.print_exc() + backoff.fail()