173 lines
6.1 KiB
Python
Executable file
173 lines
6.1 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")
|
|
|
|
# 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()
|