Skip to content
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
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:slack-sdk-link-commands", OrganizationFeature, FeatureHandlerStrategy.OPTIONS)
# Use new Slack SDK Client in SlackActionEndpoint
manager.add("organizations:slack-sdk-webhook-handling", OrganizationFeature, FeatureHandlerStrategy.OPTIONS)
# Use new Slack SDK Client in SlackActionEndpoint's `view.open`
manager.add("organizations:slack-sdk-action-view-open", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
# Use new Slack SDK Client for SlackNotifyBasicMixin
manager.add("organizations:slack-sdk-notify-mixin", OrganizationFeature, FeatureHandlerStrategy.OPTIONS)
# Add regression chart as image to slack message
Expand Down
203 changes: 157 additions & 46 deletions src/sentry/integrations/slack/webhooks/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder
from sentry.integrations.slack.requests.action import SlackActionRequest
from sentry.integrations.slack.requests.base import SlackRequestError
from sentry.integrations.slack.sdk_client import SlackSdkClient
from sentry.integrations.slack.views.link_identity import build_linking_url
from sentry.integrations.slack.views.unlink_identity import build_unlinking_url
from sentry.integrations.types import ExternalProviderEnum
Expand Down Expand Up @@ -295,7 +296,7 @@ def on_status(
user_id=user.id,
)

def build_resolve_modal_payload(self, callback_id):
def build_resolve_modal_payload_deprecated(self, callback_id) -> dict[str, Any]:
formatted_resolve_options = []
for text, value in RESOLVE_OPTIONS.items():
formatted_resolve_options.append(
Expand Down Expand Up @@ -337,7 +338,7 @@ def build_resolve_modal_payload(self, callback_id):
"callback_id": callback_id,
}

def build_archive_modal_payload(self, callback_id):
def build_archive_modal_payload_deprecated(self, callback_id) -> dict[str, Any]:
formatted_archive_options = []
for text, value in ARCHIVE_OPTIONS.items():
formatted_archive_options.append(
Expand Down Expand Up @@ -382,6 +383,78 @@ def build_archive_modal_payload(self, callback_id):
"callback_id": callback_id,
}

def build_format_options(self, options: dict[str, str]) -> list[dict[str, Any]]:
return [
{
"text": {
"type": "plain_text",
"text": text,
"emoji": True,
},
"value": value,
}
for text, value in options.items()
]

def build_modal_payload(
self,
title: str,
action_text: str,
options: dict[str, str],
initial_option_text: str,
initial_option_value: str,
callback_id: str,
) -> View:
formatted_options = self.build_format_options(options)

return View(
type="modal",
title={"type": "plain_text", "text": f"{title} Issue"},
blocks=[
{
"type": "section",
"text": {"type": "mrkdwn", "text": action_text},
"accessory": {
"type": "static_select",
"initial_option": {
"text": {
"type": "plain_text",
"text": initial_option_text,
"emoji": True,
},
"value": initial_option_value,
},
"options": formatted_options,
"action_id": "static_select-action",
},
}
],
close={"type": "plain_text", "text": "Cancel"},
submit={"type": "plain_text", "text": title},
private_metadata=callback_id,
callback_id=callback_id,
)

def build_resolve_modal_payload(self, callback_id: str) -> View:
return self.build_modal_payload(
title="Resolve",
action_text="Resolve",
options=RESOLVE_OPTIONS,
initial_option_text="Immediately",
initial_option_value="resolved",
callback_id=callback_id,
)

def build_archive_modal_payload(self, callback_id: str) -> View:
return self.build_modal_payload(
title="Archive",
action_text="Archive",
options=ARCHIVE_OPTIONS,
initial_option_text="Until escalating",
initial_option_value="ignored:archived_until_escalating",
callback_id=callback_id,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


def open_resolve_dialog(self, slack_request: SlackActionRequest, group: Group) -> None:
# XXX(epurkhiser): In order to update the original message we have to
# keep track of the response_url in the callback_id. Definitely hacky,
Expand All @@ -399,32 +472,50 @@ def open_resolve_dialog(self, slack_request: SlackActionRequest, group: Group) -
callback_id["rule"] = slack_request.callback_data.get("rule")
callback_id = orjson.dumps(callback_id).decode()

slack_client = SlackClient(integration_id=slack_request.integration.id)

# XXX(CEO): the second you make a selection (without hitting Submit) it sends a slightly different request
modal_payload = self.build_resolve_modal_payload(callback_id)
try:
payload = {
"view": orjson.dumps(modal_payload).decode(),
"trigger_id": slack_request.data["trigger_id"],
}
headers = {"content-type": "application/json; charset=utf-8"}
slack_client.post(
"/views.open",
data=orjson.dumps(payload).decode(),
headers=headers,
)
except ApiError as e:
logger.exception(
"slack.action.response-error",
extra={
"error": str(e),
"organization_id": org.id,
"integration_id": slack_request.integration.id,
if features.has("organizations:slack-sdk-action-view-open", group.project.organization):
modal_payload = self.build_resolve_modal_payload(callback_id)
slack_client = SlackSdkClient(integration_id=slack_request.integration.id)
try:
slack_client.views_open(
trigger_id=slack_request.data["trigger_id"],
view=modal_payload,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any chance we can make modal_payload into a View from slack_sdk? views_open converts the View back into a dict but it would be good to verify the payload

)
except SlackApiError:
logger.exception(
"slack.action.response-error",
extra={
"organization_id": org.id,
"integration_id": slack_request.integration.id,
"trigger_id": slack_request.data["trigger_id"],
"dialog": "resolve",
},
)
else:
modal_payload = self.build_resolve_modal_payload_deprecated(callback_id)
slack_client = SlackClient(integration_id=slack_request.integration.id)
try:
payload = {
"view": orjson.dumps(modal_payload).decode(),
"trigger_id": slack_request.data["trigger_id"],
"dialog": "resolve",
},
)
}
headers = {"content-type": "application/json; charset=utf-8"}
slack_client.post(
"/views.open",
data=orjson.dumps(payload).decode(),
headers=headers,
)
except ApiError as e:
logger.exception(
"slack.action.response-error",
extra={
"error": str(e),
"organization_id": org.id,
"integration_id": slack_request.integration.id,
"trigger_id": slack_request.data["trigger_id"],
"dialog": "resolve",
},
Comment thread
RyanSkonnord marked this conversation as resolved.
)

def open_archive_dialog(self, slack_request: SlackActionRequest, group: Group) -> None:
org = group.project.organization
Expand All @@ -440,30 +531,50 @@ def open_archive_dialog(self, slack_request: SlackActionRequest, group: Group) -
callback_id["channel_id"] = slack_request.data["channel"]["id"]
callback_id = orjson.dumps(callback_id).decode()

slack_client = SlackClient(integration_id=slack_request.integration.id)
modal_payload = self.build_archive_modal_payload(callback_id)
try:
if features.has("organizations:slack-sdk-action-view-open", group.project.organization):
modal_payload = self.build_archive_modal_payload(callback_id)
slack_client = SlackSdkClient(integration_id=slack_request.integration.id)
try:
slack_client.views_open(
trigger_id=slack_request.data["trigger_id"],
view=modal_payload,
)
except SlackApiError:
logger.exception(
"slack.action.response-error",
extra={
"organization_id": org.id,
"integration_id": slack_request.integration.id,
"trigger_id": slack_request.data["trigger_id"],
"dialog": "archive",
},
)
else:
modal_payload = self.build_archive_modal_payload_deprecated(callback_id)
payload = {
"view": orjson.dumps(modal_payload).decode(),
"trigger_id": slack_request.data["trigger_id"],
}
headers = {"content-type": "application/json; charset=utf-8"}
slack_client.post(
"/views.open",
data=orjson.dumps(payload).decode(),
headers=headers,
)
except ApiError as e:
logger.exception(
"slack.action.response-error",
extra={
"error": str(e),
"organization_id": org.id,
"integration_id": slack_request.integration.id,
"trigger_id": slack_request.data["trigger_id"],
"dialog": "archive",
},
)

slack_client = SlackClient(integration_id=slack_request.integration.id)
try:
headers = {"content-type": "application/json; charset=utf-8"}
slack_client.post(
"/views.open",
data=orjson.dumps(payload).decode(),
headers=headers,
)
except ApiError as e:
logger.exception(
"slack.action.response-error",
extra={
"error": str(e),
"organization_id": org.id,
"integration_id": slack_request.integration.id,
"trigger_id": slack_request.data["trigger_id"],
"dialog": "archive",
},
)

def construct_reply(self, attachment: SlackBody, is_message: bool = False) -> SlackBody:
# XXX(epurkhiser): Slack is inconsistent about it's expected responses
Expand Down
17 changes: 17 additions & 0 deletions tests/sentry/integrations/slack/webhooks/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import orjson
import pytest
from slack_sdk.web import SlackResponse
from slack_sdk.webhook import WebhookResponse

from sentry.testutils.cases import APITestCase
Expand Down Expand Up @@ -35,6 +36,22 @@ def mock_webhook_send(self):
) as self.mock_post:
yield

@pytest.fixture(autouse=True)
def mock_view_open(self):
with patch(
"slack_sdk.web.client.WebClient.views_open",
return_value=SlackResponse(
client=None,
http_verb="POST",
api_url="https://api.slack.com/methods/views.open",
req_args={},
data={"ok": True},
headers={},
status_code=200,
),
) as self.mock_view:
yield

@patch(
"sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret",
return_value=True,
Expand Down
Loading