#!/usr/bin/env python3 import argparse import os import sys import threading 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 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] def get_slack_channel_for_zulip_message( msg: Dict[str, Any], zulip_to_slack_map: Dict[StreamTopicT, Any], bot_email: str ) -> Optional[str]: is_a_stream = msg["type"] == "stream" if not is_a_stream: return None stream_name = msg["display_recipient"] topic_name = msg["subject"] stream_topic: StreamTopicT = (stream_name, topic_name) if stream_topic not in zulip_to_slack_map: return None # We do this to identify the messages generated from Slack -> Zulip # and we make sure we don't forward it again to the Slack. from_zulip_bot = msg["sender_email"] == bot_email if from_zulip_bot: return None return zulip_to_slack_map[stream_topic] 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() } # zulip-specific self.zulip_client = zulip.Client( email=self.zulip_config["email"], api_key=self.zulip_config["api_key"], site=self.zulip_config["site"], ) # slack-specific self.channel = self.slack_config["channel"] 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"]) 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: slack_channel = get_slack_channel_for_zulip_message( msg, self.zulip_to_slack_map, self.zulip_config["email"] ) if slack_channel is not None: self.wrap_slack_mention_with_bracket(msg) slack_text = SLACK_MESSAGE_TEMPLATE.format( username=msg["sender_full_name"], message=msg["content"] ) self.slack_webclient.chat_postMessage( channel=slack_channel, text=slack_text, ) 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.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) config: Dict[str, Any] = bridge_with_slack_config.config if "channel_mapping" not in config: print( 'The key "channel_mapping" is not found in bridge_with_slack_config.py.\n' "Your config file may be outdated." ) exit(1) print("Starting slack mirroring bot") print("MAKE SURE THE BOT IS SUBSCRIBED TO THE RELEVANT ZULIP STREAM") # 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: sb = SlackBridge(config) 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() zp.join() sp.join() except Exception: traceback.print_exc() backoff.fail()