Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Slack bridge: Update Slack bridge with Events API. #826

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/zulip-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.8
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.8"
python-version: "3.10"

- name: Install dependencies
run: tools/provision --force
Expand Down
52 changes: 32 additions & 20 deletions zulip/integrations/bridge_with_slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,42 @@ 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`

2. (Important) Subscribe the bot user to the Zulip channel you'd like to bridge
your Slack channel into.

3. Create a [Slack Webhook integration bot](https://zulip.com/integrations/doc/slack)
to get messages form Slack to Zulip.

4. In the `zulip` section of the configuration file, fill `integration_bot_email`
with **Integration bot**'s email. Note that this is the bot you created in
step 3 and not in step 1.

5. Also in the `zulip` section, enter the **Generic bot's** `zuliprc`
details (`email`, `api_key`, and `site`).
4. In the same section, also enter the Zulip `stream` and `topic`.

6. Moving to over the `channel_mapping` section, enter the Zulip `channel` and `topic`.
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 have use 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 and make sure:
- "Bot Token Scopes" includes: `chat:write`
- "User Tokens 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" (legacy).

4. In the `slack` section of the Zulip-Slack bridge configuration file, enter the bot name
(e.g "zulip_mirror"), token (e.g xoxb-...), and the channel ID (note: must be ID, not name).

### Running the bridge

Run `python3 run-slack-bridge`
Run Slack Bridge: `python3 run-slack-bridge`
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
"email": "[email protected]",
"api_key": "put api key here",
"site": "https://chat.zulip.org",
"integration_bot_email": "[email protected]",
},
"slack": {
"username": "slack_username",
"token": "xoxb-your-slack-token",
"token": "xoxp-your-slack-token",
},
# Mapping between Slack channels and Zulip stream-topic's.
# You can specify multiple pairs.
"channel_mapping": {
# Slack channel; must be channel ID
"C5Z5N7R8A": {
# Zulip stream
"stream": "test here",
# Zulip channel
"channel": "test here",
# Zulip topic
"topic": "<- slack-bridge",
},
Expand Down
81 changes: 29 additions & 52 deletions zulip/integrations/bridge_with_slack/run-slack-bridge
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -65,31 +74,28 @@ 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(" ")
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 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("integration_bot_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"]
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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()
7 changes: 3 additions & 4 deletions zulip_bots/zulip_bots/bots/salesforce/salesforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Collection, Dict, List

import simple_salesforce
from simple_salesforce import Salesforce # type: ignore[attr-defined]

from zulip_bots.bots.salesforce.utils import commands, default_query, link_query, object_types
from zulip_bots.lib import BotHandler
Expand Down Expand Up @@ -73,9 +74,7 @@ def format_result(
return output


def query_salesforce(
arg: str, salesforce: simple_salesforce.Salesforce, command: Dict[str, Any]
) -> str:
def query_salesforce(arg: str, salesforce: Salesforce, command: Dict[str, Any]) -> str:
arg = arg.strip()
qarg = arg.split(" -", 1)[0]
split_args: List[str] = []
Expand Down Expand Up @@ -164,7 +163,7 @@ def get_salesforce_response(self, content: str) -> str:
def initialize(self, bot_handler: BotHandler) -> None:
self.config_info = bot_handler.get_config_info("salesforce")
try:
self.sf = simple_salesforce.Salesforce(
self.sf = Salesforce(
username=self.config_info["username"],
password=self.config_info["password"],
security_token=self.config_info["security_token"],
Expand Down
Loading