Skip to content

Commit

Permalink
Merge pull request #6020 from RasaHQ/slack-enhancements
Browse files Browse the repository at this point in the history
Slack Button Fix and Threaded Messaging
  • Loading branch information
rasabot authored Jul 27, 2020
2 parents b5f6139 + eeaf1ff commit 6634a4c
Show file tree
Hide file tree
Showing 5 changed files with 550 additions and 101 deletions.
11 changes: 11 additions & 0 deletions changelog/6020.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
You can now enable threaded message responses from Rasa through the Slack connector.
This option is enabled using an optional configuration in the credentials.yml file

.. code-block:: none
slack:
slack_token:
slack_channel:
use_threads: True
Button support has also been added in the Slack connector.
23 changes: 22 additions & 1 deletion docs/core/domains.rst
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,28 @@ output payloads that will only work in certain channels.
- text: "Which game would you like to play?"
channel: "slack"
custom:
- # payload for Slack dropdown menu to choose a game
blocks:
- type: actions
elements:
- type: button
text:
type: plain_text
emoji: true
text: "Chess :crown:"
value: '/inform{"game": "chess"}'
- type: button
text:
type: plain_text
emoji: true
text: "Checkers :checkered_flag:"
value: '/inform{"game": "checkers"}'
- type: button
text:
type: plain_text
emoji: true
text: "Fortnite :european_castle:"
value: '/inform{"game": "fortnite"}'
style: danger
- text: "Which game would you like to play?"
buttons:
- title: "Chess"
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/connectors/slack.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ You need to supply a ``credentials.yml`` with the following content:
slack_retry_reason_header: "x-slack-retry-reason" # Slack HTTP header name indicating reason that slack send retry request. This configuration is optional.
slack_retry_number_header: "x-slack-retry-num" # Slack HTTP header name indicating the attempt number. This configuration is optional.
errors_ignore_retry: None # Any error codes given by Slack included in this list will be ignored. Error codes are listed `here <https://api.slack.com/events-api#errors>`_.
use_threads: False # If set to True, bot responses will appear as a threaded message in Slack. This configuration is optional and set to False by default.
The endpoint for receiving slack messages is
``http://localhost:5005/webhooks/slack/webhook``, replacing
Expand Down
122 changes: 87 additions & 35 deletions rasa/core/channels/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,34 @@ class SlackBot(OutputChannel):
def name(cls) -> Text:
return "slack"

def __init__(self, token: Text, slack_channel: Optional[Text] = None) -> None:
def __init__(
self,
token: Text,
slack_channel: Optional[Text] = None,
thread_id: Optional[Text] = None,
) -> None:

self.slack_channel = slack_channel
self.thread_id = thread_id
self.client = WebClient(token, run_async=True)
super().__init__()

@staticmethod
def _get_text_from_slack_buttons(buttons: List[Dict]) -> Text:
return "".join([b.get("title", "") for b in buttons])

async def _post_message(self, **kwargs: Any):
if self.thread_id:
await self.client.chat_postMessage(**kwargs, thread_ts=self.thread_id)
else:
await self.client.chat_postMessage(**kwargs)

async def send_text_message(
self, recipient_id: Text, text: Text, **kwargs: Any
) -> None:
recipient = self.slack_channel or recipient_id
for message_part in text.strip().split("\n\n"):
await self.client.chat_postMessage(
await self._post_message(
channel=recipient, as_user=True, text=message_part, type="mrkdwn",
)

Expand All @@ -44,15 +56,16 @@ async def send_image_url(
) -> None:
recipient = self.slack_channel or recipient_id
image_block = {"type": "image", "image_url": image, "alt_text": image}
await self.client.chat_postMessage(

await self._post_message(
channel=recipient, as_user=True, text=image, blocks=[image_block],
)

async def send_attachment(
self, recipient_id: Text, attachment: Dict[Text, Any], **kwargs: Any
) -> None:
recipient = self.slack_channel or recipient_id
await self.client.chat_postMessage(
await self._post_message(
channel=recipient, as_user=True, attachments=[attachment], **kwargs,
)

Expand Down Expand Up @@ -83,7 +96,8 @@ async def send_text_with_buttons(
"value": button["payload"],
}
)
await self.client.chat_postMessage(

await self._post_message(
channel=recipient,
as_user=True,
text=text,
Expand All @@ -95,7 +109,7 @@ async def send_custom_json(
) -> None:
json_message.setdefault("channel", self.slack_channel or recipient_id)
json_message.setdefault("as_user", True)
await self.client.chat_postMessage(**json_message)
await self._post_message(**json_message)


class SlackInput(InputChannel):
Expand All @@ -117,6 +131,7 @@ def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChanne
credentials.get("slack_retry_reason_header", "x-slack-retry-reason"),
credentials.get("slack_retry_number_header", "x-slack-retry-num"),
credentials.get("errors_ignore_retry", None),
credentials.get("use_threads", False),
)
# pytype: enable=attribute-error

Expand All @@ -127,6 +142,7 @@ def __init__(
slack_retry_reason_header: Optional[Text] = None,
slack_retry_number_header: Optional[Text] = None,
errors_ignore_retry: Optional[List[Text]] = None,
use_threads: Optional[bool] = False,
) -> None:
"""Create a Slack input channel.
Expand All @@ -149,13 +165,16 @@ def __init__(
included in this list will be ignored.
Error codes are listed
`here <https://api.slack.com/events-api#errors>`_.
use_threads: If set to True, your bot will send responses in Slack as a threaded message.
Responses will appear as a normal Slack message if set to False.
"""
self.slack_token = slack_token
self.slack_channel = slack_channel
self.errors_ignore_retry = errors_ignore_retry or ("http_timeout",)
self.retry_reason_header = slack_retry_reason_header
self.retry_num_header = slack_retry_number_header
self.use_threads = use_threads

@staticmethod
def _is_app_mention(slack_event: Dict) -> bool:
Expand Down Expand Up @@ -297,13 +316,18 @@ async def process_message(

if metadata is not None:
output_channel = metadata.get("out_channel")
if self.use_threads:
thread_id = metadata.get("ts")
else:
thread_id = None
else:
output_channel = None
thread_id = None

try:
user_msg = UserMessage(
text,
self.get_output_channel(output_channel),
self.get_output_channel(output_channel, thread_id),
sender_id,
input_channel=self.name(),
metadata=metadata,
Expand All @@ -326,13 +350,34 @@ def get_metadata(self, request: Request) -> Dict[Text, Any]:
Metadata extracted from the sent event payload. This includes the output channel for the response,
and users that have installed the bot.
"""
slack_event = request.json
event = slack_event.get("event", {})

return {
"out_channel": event.get("channel"),
"users": slack_event.get("authed_users"),
}
content_type = request.headers.get("content-type")

# Slack API sends either a JSON-encoded or a URL-encoded body depending on the content
if content_type == "application/json":
# if JSON-encoded message is received
slack_event = request.json
event = slack_event.get("event", {})
thread_id = event.get("thread_ts", event.get("ts"))

return {
"out_channel": event.get("channel"),
"thread_id": thread_id,
"users": slack_event.get("authed_users"),
}

if content_type == "application/x-www-form-urlencoded":
# if URL-encoded message is received
output = request.form
payload = json.loads(output["payload"][0])
message = payload.get("message", {})
thread_id = message.get("thread_ts", message.get("ts"))
return {
"out_channel": payload.get("channel", {}).get("id"),
"thread_id": thread_id,
"users": payload.get("user", {}).get("id"),
}

return {}

def blueprint(
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
Expand All @@ -345,26 +390,11 @@ async def health(_: Request) -> HTTPResponse:

@slack_webhook.route("/webhook", methods=["GET", "POST"])
async def webhook(request: Request) -> HTTPResponse:
if request.form:
output = request.form
payload = json.loads(output["payload"][0])

if self._is_interactive_message(payload):
sender_id = payload["user"]["id"]
text = self._get_interactive_response(payload["actions"][0])
if text is not None:
metadata = self.get_metadata(request)
return await self.process_message(
request, on_new_message, text, sender_id, metadata
)
elif payload["actions"][0]["type"] == "button":
# link buttons don't have "value", don't send their clicks to bot
return response.text("User clicked link button")
return response.text(
"The input message could not be processed.", status=500
)
content_type = request.headers.get("content-type")
# Slack API sends either a JSON-encoded or a URL-encoded body depending on the content

elif request.json:
if content_type == "application/json":
# if JSON-encoded message is received
output = request.json
event = output.get("event", {})
user_message = event.get("text", "")
Expand All @@ -391,6 +421,26 @@ async def webhook(request: Request) -> HTTPResponse:
f"Received message on unsupported channel: {metadata['out_channel']}"
)

elif content_type == "application/x-www-form-urlencoded":
# if URL-encoded message is received
output = request.form
payload = json.loads(output["payload"][0])

if self._is_interactive_message(payload):
sender_id = payload["user"]["id"]
text = self._get_interactive_response(payload["actions"][0])
if text is not None:
metadata = self.get_metadata(request)
return await self.process_message(
request, on_new_message, text, sender_id, metadata
)
if payload["actions"][0]["type"] == "button":
# link buttons don't have "value", don't send their clicks to bot
return response.text("User clicked link button")
return response.text(
"The input message could not be processed.", status=500
)

return response.text("Bot message delivered.")

return slack_webhook
Expand All @@ -402,9 +452,11 @@ def _is_supported_channel(self, slack_event: Dict, metadata: Dict) -> bool:
or metadata["out_channel"] == self.slack_channel
)

def get_output_channel(self, channel: Optional[Text] = None) -> OutputChannel:
def get_output_channel(
self, channel: Optional[Text] = None, thread_id: Optional[Text] = None
) -> OutputChannel:
channel = channel or self.slack_channel
return SlackBot(self.slack_token, channel)
return SlackBot(self.slack_token, channel, thread_id)

def set_output_channel(self, channel: Text) -> None:
self.slack_channel = channel
Loading

0 comments on commit 6634a4c

Please sign in to comment.