Skip to content

Commit

Permalink
Merge pull request #201 from sdoiron0330/slash-commands-in-thread
Browse files Browse the repository at this point in the history
App Mention/Responding in Threads
  • Loading branch information
smk4664 authored Jun 2, 2023
2 parents 89385bc + d1c147a commit 692cece
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 10 deletions.
1 change: 1 addition & 0 deletions changes/67.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the ability within Slack to mention the chat bot by name in a channel and in threads.
1 change: 1 addition & 0 deletions changes/67.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Modified the clear command to not work within Slack threads.
3 changes: 2 additions & 1 deletion docs/admin/install/slack_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ While there are sufficient ways of securing inbound API requests from the public
- On line 34, update `socket_mode_enabled` to `true`
- If not using Socket mode:
- On line 12, under setting `features/slash_commands/url`, update `<your-URL>` with a publicly accessible URL to your Nautobot server. Note: The trailing `/api/plugins/...` are required.
- Repeat this for line 31, under setting `settings/interactivity/request_url`
- Repeat this for line 30, under setting `settings/request_url/request_url`
- Repeat this for line 35, under setting `settings/interactivity/request_url`
- On line 34, verify `socket_mode_enabled` is set to `false`
4. Review the summarized settings on the next window and click Create.
5. On the General --> Basic Information page, note the `Signing Secret` near the bottom, under App Credentials. This will be needed later for setting `SLACK_SIGNING_SECRET`.
Expand Down
6 changes: 6 additions & 0 deletions docs/user/app_faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ If a 404 error is being returned while trying to use a slash command that allows
- Navigate to [https://api.slack.com/apps](https://api.slack.com/apps) and select your Nautobot ChatOps application that is currently in development
- Under **Features**, navigate to "Interactivity & Shortcuts"
- Under **Interactivity**, confirm that the Request URL is of the format: `https://<server>/api/plugins/chatops/slack/interaction/` (Note the trailing slash)

## Can I interact with Nautobot within a Slack thread?

Slack does not currently support using slash commands within a conversation thread. Nautobot can be mentioned in a thread and will parse the text after the bot's name for a command.

For example, if you want to run the slash command `/nautobot get-devices site site-a`, the equivalent bot mention command would be (assuming your bot name is `@nautobot`) `@nautobot nautobot get-devices site site-a`.
3 changes: 2 additions & 1 deletion nautobot_chatops/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
urlpatterns = [path("lookup/", AccessLookupView.as_view(), name="access_lookup")]

if settings.PLUGINS_CONFIG["nautobot_chatops"].get("enable_slack"):
from nautobot_chatops.api.views.slack import SlackSlashCommandView, SlackInteractionView
from nautobot_chatops.api.views.slack import SlackSlashCommandView, SlackInteractionView, SlackEventAPIView

urlpatterns += [
path("slack/slash_command/", SlackSlashCommandView.as_view(), name="slack_slash_command"),
path("slack/interaction/", SlackInteractionView.as_view(), name="slack_interaction"),
path("slack/event/", SlackEventAPIView.as_view(), name="slack_event"),
]

if settings.PLUGINS_CONFIG["nautobot_chatops"].get("enable_ms_teams"):
Expand Down
48 changes: 48 additions & 0 deletions nautobot_chatops/api/views/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def post(self, request, *args, **kwargs):
"user_name": payload.get("user", {}).get("username"),
"response_url": payload.get("response_url"),
"trigger_id": payload.get("trigger_id"),
"thread_ts": payload.get("event", {}).get("event_ts") or payload.get("container", {}).get("thread_ts"),
}

# Check for channel_name if channel_id is present
Expand Down Expand Up @@ -239,6 +240,8 @@ def post(self, request, *args, **kwargs):
private_metadata = json.loads(payload["view"]["private_metadata"])
if "channel_id" in private_metadata:
context["channel_id"] = private_metadata["channel_id"]
if "thread_ts" in private_metadata:
context["thread_ts"] = private_metadata["thread_ts"]
else:
return HttpResponse("I didn't understand that notification.")

Expand Down Expand Up @@ -268,3 +271,48 @@ def post(self, request, *args, **kwargs):
# SlackDispatcher(context).send_busy_indicator()

return check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher)


@method_decorator(csrf_exempt, name="dispatch")
class SlackEventAPIView(View):
"""Handle notifications resulting from a mention of the Slack app."""

http_method_names = ["post"]

# pylint: disable=too-many-locals,too-many-return-statements,too-many-branches,too-many-statements
def post(self, request, *args, **kwargs):
"""Handle an inbound HTTP POST request representing an app mention."""
valid, reason = verify_signature(request)
if not valid:
return HttpResponse(status=401, reason=reason)

# event api data is in request body
event = json.loads(request.body.decode("utf-8"))
# url verification happens when you add the request URL to the app manifest
if event.get("type") == "url_verification":
return HttpResponse(event.get("challenge"))

context = {
"org_id": event.get("team_id"),
"org_name": event.get("team_domain"),
"channel_id": event.get("event", {}).get("channel"),
"channel_name": event.get("channel_name"),
"user_id": event.get("event", {}).get("user"),
"user_name": event.get("event", {}).get("user"),
"thread_ts": event.get("event", {}).get("thread_ts"),
}
bot_id = event.get("authorizations", [{}])[0].get("user_id")
text_after_mention = event.get("event", {}).get("text").split(f"<@{bot_id}>")[-1]
text_after_mention = text_after_mention.replace(SLASH_PREFIX, "")
try:
command, subcommand, params = parse_command_string(text_after_mention)
except ValueError as err:
logger.error("%s", err)
return HttpResponse(f"'Error: {err}' encountered on command '{text_after_mention}'.")

registry = get_commands_registry()

if command not in registry:
SlackDispatcher(context).send_markdown(commands_help(prefix=SLASH_PREFIX))

return check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher)
19 changes: 13 additions & 6 deletions nautobot_chatops/dispatchers/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,14 @@ def send_markdown(self, message, ephemeral=None):
channel=self.context.get("channel_id"),
user=self.context.get("user_id"),
text=message,
thread_ts=self.context.get("thread_ts"),
)
else:
self.slack_client.chat_postMessage(
channel=self.context.get("channel_id"),
user=self.context.get("user_id"),
text=message,
thread_ts=self.context.get("thread_ts"),
)
except SlackClientError as slack_error:
self.send_exception(slack_error)
Expand Down Expand Up @@ -195,9 +197,7 @@ def send_blocks(self, blocks, callback_id=None, modal=False, ephemeral=None, tit
"blocks": blocks,
# Embed the current channel information into to the modal as modals don't store this otherwise
"private_metadata": json.dumps(
{
"channel_id": self.context.get("channel_id"),
}
{"channel_id": self.context.get("channel_id"), "thread_ts": self.context.get("thread_ts")}
),
"callback_id": callback_id,
},
Expand All @@ -207,12 +207,14 @@ def send_blocks(self, blocks, callback_id=None, modal=False, ephemeral=None, tit
channel=self.context.get("channel_id"),
user=self.context.get("user_id"),
blocks=blocks,
thread_ts=self.context.get("thread_ts"),
)
else:
self.slack_client.chat_postMessage(
channel=self.context.get("channel_id"),
user=self.context.get("user_id"),
blocks=blocks,
thread_ts=self.context.get("thread_ts"),
)
except SlackClientError as slack_error:
self.send_exception(slack_error)
Expand All @@ -235,9 +237,14 @@ def send_snippet(self, text, title=None, ephemeral=None):
message_list = self.split_message(text, SLACK_PRIVATE_MESSAGE_LIMIT)
for msg in message_list:
# Send the blocks as a list, this needs to be the case for Slack to send appropriately.
self.send_blocks([self.markdown_block(f"```\n{msg}\n```")], ephemeral=ephemeral)
self.send_blocks(
[self.markdown_block(f"```\n{msg}\n```")],
ephemeral=ephemeral,
)
else:
self.slack_client.files_upload(channels=channels, content=text, title=title)
self.slack_client.files_upload(
channels=channels, content=text, title=title, thread_ts=self.context.get("thread_ts")
)
except SlackClientError as slack_error:
self.send_exception(slack_error)

Expand All @@ -263,7 +270,7 @@ def send_image(self, image_path):
channels = [self.context.get("channel_id")]
channels = ",".join(channels)
logger.info("Sending image %s to %s", image_path, channels)
self.slack_client.files_upload(channels=channels, file=image_path)
self.slack_client.files_upload(channels=channels, file=image_path, thread_ts=self.context.get("thread_ts"))

def send_warning(self, message):
"""Send a warning message to the user/channel specified by the context."""
Expand Down
36 changes: 36 additions & 0 deletions nautobot_chatops/sockets/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ async def process(client: SocketModeClient, req: SocketModeRequest):
await client.send_socket_mode_response(response)
await process_interactive(client, req)

if req.type == "events_api" and req.payload.get("event", {}).get("type") == "app_mention":
client.logger.debug("Received mention of bot")
response = SocketModeResponse(envelope_id=req.envelope_id)
await client.send_socket_mode_response(response)
await process_mention(client, req)

async def process_slash_command(client, req):
client.logger.debug("Processing slash command.")
command = req.payload.get("command")
Expand Down Expand Up @@ -86,6 +92,8 @@ async def process_interactive(client, req):
"user_name": payload.get("user", {}).get("username"),
"response_url": payload.get("response_url"),
"trigger_id": payload.get("trigger_id"),
"thread_ts": req.payload.get("event", {}).get("event_ts")
or req.payload.get("container", {}).get("thread_ts"),
}

# Check for channel_name if channel_id is present
Expand Down Expand Up @@ -181,6 +189,8 @@ async def process_interactive(client, req):
private_metadata = json.loads(payload["view"]["private_metadata"])
if "channel_id" in private_metadata:
context["channel_id"] = private_metadata["channel_id"]
if "thread_ts" in private_metadata:
context["thread_ts"] = private_metadata["thread_ts"]
else:
client.logger.error("I didn't understand that notification.")
return
Expand Down Expand Up @@ -211,6 +221,32 @@ async def process_interactive(client, req):

return await socket_check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher)

async def process_mention(client, req):
context = {
"org_id": req.payload.get("team_id"),
"org_name": req.payload.get("team_domain"),
"channel_id": req.payload.get("event", {}).get("channel"),
"channel_name": req.payload.get("channel_name"),
"user_id": req.payload.get("event", {}).get("user"),
"user_name": req.payload.get("event", {}).get("user"),
"thread_ts": req.payload.get("event", {}).get("thread_ts"),
}
bot_id = req.payload.get("authorizations", [{}])[0].get("user_id")
text_after_mention = req.payload.get("event", {}).get("text").split(f"<@{bot_id}>")[-1]
text_after_mention = text_after_mention.replace(SLASH_PREFIX, "")
try:
command, subcommand, params = parse_command_string(text_after_mention)
except ValueError as err:
client.logger.error("%s", err)
return

registry = get_commands_registry()

if command not in registry:
SlackDispatcher(context).send_markdown(commands_help(prefix=SLASH_PREFIX))

return await socket_check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher)

client.socket_mode_request_listeners.append(process)

await client.connect()
Expand Down
30 changes: 28 additions & 2 deletions nautobot_chatops/tests/test_dispatchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,16 @@ def test_send_snippet_no_title(self):
"""Make sure files_upload is called with no title."""
with patch.object(self.dispatcher.slack_client, "files_upload") as mocked_files_upload:
self.dispatcher.send_snippet("Testing files upload.")
mocked_files_upload.assert_called_with(channels="456def", content="Testing files upload.", title=None)
mocked_files_upload.assert_called_with(
channels="456def", content="Testing files upload.", title=None, thread_ts=None
)

def test_send_snippet_title(self):
"""Make sure files_upload is called with title."""
with patch.object(self.dispatcher.slack_client, "files_upload") as mocked_files_upload:
self.dispatcher.send_snippet("Testing files upload.", "Testing files upload title.")
mocked_files_upload.assert_called_with(
channels="456def", content="Testing files upload.", title="Testing files upload title."
channels="456def", content="Testing files upload.", title="Testing files upload title.", thread_ts=None
)

@patch("nautobot_chatops.dispatchers.slack.SlackDispatcher.send_blocks")
Expand Down Expand Up @@ -197,6 +199,15 @@ def test_user_session_key(self):
# This should not raise an exception
self.dispatcher.unset_session_entry("key1")

def test_thread_ts_passed_into_slack_client(self):
"""Test thread_ts being passed correctly when it exists in the context."""
self.dispatcher.context.update({"thread_ts": "12345"})
with patch.object(self.dispatcher.slack_client, "chat_postMessage") as mocked_chat_post_message:
self.dispatcher.send_markdown("test message")
mocked_chat_post_message.assert_called_with(
channel="456def", user="abc123", text="test message", thread_ts="12345"
)


class TestMSTeamsDispatcher(TestSlackDispatcher):
"""Test the MSTeamsDispatcher class."""
Expand Down Expand Up @@ -230,6 +241,11 @@ def test_multi_input_dialog(self):
# pylint: disable=W0221
pass

def test_thread_ts_passed_into_slack_client(self):
"""thread_ts is a Slack specific implementation."""
# pylint: disable=W0221
pass


class TestWebExDispatcher(TestSlackDispatcher):
"""Test the WebExDispatcher class."""
Expand Down Expand Up @@ -261,6 +277,11 @@ def test_multi_input_dialog(self):
# pylint: disable=W0221
pass

def test_thread_ts_passed_into_slack_client(self):
"""thread_ts is a Slack specific implementation."""
# pylint: disable=W0221
pass

@patch("nautobot_chatops.dispatchers.webex.WebExDispatcher.send_markdown")
def test_send_large_table(self, mock_send_markdown):
"""Make sure send_large_table() is implemented."""
Expand Down Expand Up @@ -329,6 +350,11 @@ def test_send_snippet_title(self):
# pylint: disable W0221
pass

def test_thread_ts_passed_into_slack_client(self):
"""thread_ts is a Slack specific implementation."""
# pylint: disable=W0221
pass

@patch("nautobot_chatops.dispatchers.mattermost.MattermostDispatcher.send_blocks")
def test_multi_input_dialog(self, mock_send_blocks):
"""Make sure multi_input_dialog() is implemented."""
Expand Down
6 changes: 6 additions & 0 deletions nautobot_chatops/workers/clear.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django_rq import job

from nautobot_chatops.choices import CommandStatusChoices
from nautobot_chatops.dispatchers.slack import SlackDispatcher
from nautobot_chatops.utils import create_command_log
from nautobot_chatops.workers import get_commands_registry

Expand All @@ -16,6 +17,11 @@ def clear(subcommand, params, dispatcher_class=None, context=None, **kwargs):
# This command is somewhat unique as it doesn't have any subcommands or parameters.
# Hence we don't use the usual handle_subcommands() / subcommand_of() functions used in more complex workers.
dispatcher = dispatcher_class(context=context)

# Choosing not to allow the clear function within SlackThreads.
if dispatcher_class is SlackDispatcher and context.get("thread_ts"):
dispatcher.send_markdown("Clear not supported within threads", ephemeral=True)
return CommandStatusChoices.STATUS_SUCCEEDED
command_log = create_command_log(dispatcher, get_commands_registry(), "clear", subcommand)

# 1) Markdown ignores single newlines, you need two consecutive ones to get a rendered newline
Expand Down
4 changes: 4 additions & 0 deletions setup_files/nautobot_slack_manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ oauth_config:
- im:read
- mpim:read
settings:
event_subscriptions:
request_url: https://<your-URL>/api/plugins/chatops/slack/event/
bot_events:
- app_mention
interactivity:
is_enabled: true
request_url: https://<your-URL>/api/plugins/chatops/slack/interaction/
Expand Down

0 comments on commit 692cece

Please sign in to comment.