172 lines
		
	
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			172 lines
		
	
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
| #!/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(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:
 | |
|             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()
 | 
