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

App Mention/Responding in Threads #201

Merged
merged 7 commits into from
Jun 2, 2023
Merged
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
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