diff --git a/docs/admin-guide/administration/plugins/configuring-slack.md b/docs/admin-guide/administration/plugins/configuring-slack.md index f609122aeadd..2f53098bc87c 100644 --- a/docs/admin-guide/administration/plugins/configuring-slack.md +++ b/docs/admin-guide/administration/plugins/configuring-slack.md @@ -82,6 +82,7 @@ The following are the bot and user scopes required for the Dispatch Slack App to **Bot Token Scopes** ```text +bookmarks:write channels:read chat:write commands diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 86d7e02d62a0..c626a02037a3 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -725,11 +725,16 @@ def run_slack_websocket(organization: str, project: str): """Runs the slack websocket process.""" import asyncio from sqlalchemy import true - from dispatch.project.models import ProjectRead - from dispatch.project import service as project_service - from dispatch.plugins.dispatch_slack import socket_mode - from dispatch.plugins.dispatch_slack.decorators import get_organization_scope_from_slug + + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from dispatch.common.utils.cli import install_plugins + from dispatch.plugins.dispatch_slack.bolt import app + from dispatch.plugins.dispatch_slack.incident.interactive import configure as incident_configure + from dispatch.plugins.dispatch_slack.service import get_organization_scope_from_slug + from dispatch.plugins.dispatch_slack.workflow import configure as workflow_configure + from dispatch.project import service as project_service + from dispatch.project.models import ProjectRead install_plugins() @@ -749,7 +754,7 @@ def run_slack_websocket(organization: str, project: str): instance = None for i in instances: if i.plugin.slug == "slack-conversation": - instance = i + instance: PluginInstance = i break if not instance: @@ -760,8 +765,20 @@ def run_slack_websocket(organization: str, project: str): return session.close() + click.secho("Slack websocket process started...", fg="blue") - asyncio.run(socket_mode.run_websocket_process(instance.configuration)) + incident_configure(instance.configuration) + workflow_configure(instance.configuration) + + app._token = instance.configuration.api_bot_token.get_secret_value() + + async def main(): + handler = AsyncSocketModeHandler( + app, instance.configuration.socket_mode_app_token.get_secret_value() + ) + await handler.start_async() + + asyncio.run(main()) @dispatch_server.command("shell") diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 456ead743b19..cdb4c227c0b2 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -542,6 +542,62 @@ def set_conversation_topic(incident: Incident, db_session: SessionLocal): log.exception(e) +def set_conversation_bookmarks(incident: Incident, db_session: SessionLocal): + """Sets the conversation bookmarks.""" + if not incident.conversation: + log.warning("Conversation bookmark not set. No conversation available for this incident.") + return + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning("Bookmarks not created. No conversation plugin enabled.") + return + + try: + plugin.instance.set_bookmark( + incident.conversation.channel_id, + resolve_attr(incident, "incident_document.weblink"), + title="Incident Document", + ) if incident.documents else log.warning( + "Document bookmark not set. No document available for this incident." + ) + + plugin.instance.set_bookmark( + incident.conversation.channel_id, + resolve_attr(incident, "conference.weblink"), + title="Video Conference", + ) if incident.conference else log.warning( + "Conference bookmark not set. No conference available for this incident." + ) + + plugin.instance.set_bookmark( + incident.conversation.channel_id, + resolve_attr(incident, "storage.weblink"), + title="Storage", + ) if incident.storage else log.warning( + "Storage bookmark not set. No storage available for this incident." + ) + + plugin.instance.set_bookmark( + incident.conversation.channel_id, + resolve_attr(incident, "ticket.weblink"), + title="Ticket", + ) if incident.ticket else log.warning( + "Ticket bookmark not set. No ticket available for this incident." + ) + + except Exception as e: + event_service.log_incident_event( + db_session=db_session, + source="Dispatch Core App", + description=f"Setting the incident conversation bookmarks failed. Reason: {e}", + incident_id=incident.id, + ) + log.exception(e) + + def add_participants_to_conversation( participant_emails: List[str], incident: Incident, db_session: SessionLocal ): @@ -848,9 +904,6 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session description="Conversation added to incident", incident_id=incident.id, ) - - # we set the conversation topic - set_conversation_topic(incident, db_session) except Exception as e: event_service.log_incident_event( db_session=db_session, @@ -896,6 +949,11 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session ) log.exception(e) + # we set the conversation topic + set_conversation_topic(incident, db_session) + # we set the conversation bookmarks + set_conversation_bookmarks(incident, db_session) + # we defer this setup for all resolved incident roles until after resources have been created roles = ["reporter", "commander", "liaison", "scribe"] diff --git a/src/dispatch/plugins/dispatch_slack/actions.py b/src/dispatch/plugins/dispatch_slack/actions.py deleted file mode 100644 index dd83fd88713f..000000000000 --- a/src/dispatch/plugins/dispatch_slack/actions.py +++ /dev/null @@ -1,560 +0,0 @@ -import json - -from pydantic import ValidationError - -from fastapi import BackgroundTasks - -from dispatch.conversation import service as conversation_service -from dispatch.conversation.enums import ConversationButtonActions -from dispatch.conversation.messaging import send_feedack_to_user -from dispatch.enums import Visibility -from dispatch.exceptions import DispatchException -from dispatch.incident import flows as incident_flows -from dispatch.incident import service as incident_service -from dispatch.incident.enums import IncidentStatus -from dispatch.messaging.strings import ( - INCIDENT_MONITOR_CREATED_NOTIFICATION, - INCIDENT_MONITOR_IGNORE_NOTIFICATION, -) -from dispatch.monitor import service as monitor_service -from dispatch.monitor.models import MonitorCreate -from dispatch.participant import service as participant_service -from dispatch.participant_role.enums import ParticipantRoleType -from dispatch.plugin import service as plugin_service -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.report import flows as report_flows -from dispatch.report.models import ExecutiveReportCreate, TacticalReportCreate -from dispatch.task import service as task_service -from dispatch.task.enums import TaskStatus - -from .config import SlackConfiguration, SlackConversationConfiguration - -from .modals.feedback.views import RatingFeedbackCallbackId -from .modals.feedback.handlers import ( - rating_feedback_from_submitted_form, - create_rating_feedback_modal, -) - -from .modals.workflow.views import RunWorkflowBlockId, RunWorkflowCallbackId -from .modals.workflow.handlers import run_workflow_submitted_form, update_workflow_modal - -from .modals.incident.handlers import ( - report_incident_from_submitted_form, - add_timeline_event_from_submitted_form, - update_incident_from_submitted_form, - update_notifications_group_from_submitted_form, - update_participant_from_submitted_form, - update_report_incident_modal, - update_update_participant_modal, -) - -from .modals.incident.enums import ( - AddTimelineEventCallbackId, - UpdateIncidentCallbackId, - ReportIncidentCallbackId, - UpdateParticipantCallbackId, - UpdateNotificationsGroupCallbackId, -) - -from .models import ButtonValue, MonitorButton, TaskButton - -from .service import get_user_email -from .decorators import slack_background_task, get_organization_scope_from_channel_id - - -def handle_modal_action( - config: SlackConversationConfiguration, action: dict, background_tasks: BackgroundTasks -): - """Handles all modal actions.""" - view_data = action["view"] - view_data["private_metadata"] = json.loads(view_data["private_metadata"]) - - action_id = view_data["callback_id"] - incident_id = view_data["private_metadata"].get("incident_id") - - channel_id = view_data["private_metadata"].get("channel_id") - user_id = action["user"]["id"] - user_email = action["user"]["email"] - - for f in action_functions(action_id): - background_tasks.add_task( - f, - user_id=user_id, - user_email=user_email, - config=config, - channel_id=channel_id, - incident_id=incident_id, - action=action, - ) - - -def action_functions(action_id: str): - """Determines which function needs to be run.""" - action_mappings = { - AddTimelineEventCallbackId.submit_form: [add_timeline_event_from_submitted_form], - ReportIncidentCallbackId.submit_form: [report_incident_from_submitted_form], - UpdateParticipantCallbackId.submit_form: [update_participant_from_submitted_form], - UpdateIncidentCallbackId.submit_form: [update_incident_from_submitted_form], - UpdateNotificationsGroupCallbackId.submit_form: [ - update_notifications_group_from_submitted_form - ], - RunWorkflowCallbackId.submit_form: [run_workflow_submitted_form], - RatingFeedbackCallbackId.submit_form: [rating_feedback_from_submitted_form], - } - - # this allows for unique action blocks e.g. invite-user or invite-user-1, etc - for key in action_mappings.keys(): - if key in action_id: - return action_mappings[key] - return [] - - -async def handle_slack_action(*, config, client, request, background_tasks): - """Handles slack action message.""" - # We resolve the user's email - user_id = request["user"]["id"] - - user_email = await dispatch_slack_service.get_user_email_async(client, user_id) - - request["user"]["email"] = user_email - - # When there are no exceptions within the dialog submission, your app must respond with 200 OK with an empty body. - response_body = {} - if request["type"] == "view_submission": - handle_modal_action(config, request, background_tasks) - # For modals we set "response_action" to "clear" to close all views in the modal. - # An empty body is currently not working. - response_body = {"response_action": "clear"} - elif request["type"] == "dialog_submission": - handle_dialog_action(config, request, background_tasks) - elif request["type"] == "block_actions": - handle_block_action(config, request, background_tasks) - - return response_body - - -def block_action_functions(action: str): - """Interprets the action and routes it to the appropriate function.""" - action_mappings = { - ConversationButtonActions.invite_user: [add_user_to_conversation], - ConversationButtonActions.subscribe_user: [add_user_to_tactical_group], - ConversationButtonActions.provide_feedback: [create_rating_feedback_modal], - ConversationButtonActions.update_task_status: [update_task_status], - ConversationButtonActions.monitor_link: [monitor_link], - # Note these are temporary for backward compatibility of block ids and should be remove in a future release - "ConversationButtonActions.invite_user": [add_user_to_conversation], - "ConversationButtonActions.provide_feedback": [create_rating_feedback_modal], - "ConversationButtonActions.update_task_status": [ - update_task_status, - ], - UpdateParticipantCallbackId.update_view: [update_update_participant_modal], - ReportIncidentCallbackId.update_view: [update_report_incident_modal], - RunWorkflowCallbackId.update_view: [update_workflow_modal], - RunWorkflowBlockId.workflow_select: [update_workflow_modal], - } - - # this allows for unique action blocks e.g. invite-user or invite-user-1, etc - for key in action_mappings.keys(): - if key in action: - return action_mappings[key] - return [] - - -def handle_dialog_action( - config: SlackConversationConfiguration, action: dict, background_tasks: BackgroundTasks -): - """Handles all dialog actions.""" - channel_id = action["channel"]["id"] - db_session = get_organization_scope_from_channel_id(channel_id=channel_id) - - conversation = conversation_service.get_by_channel_id_ignoring_channel_type( - db_session=db_session, channel_id=channel_id - ) - incident_id = conversation.incident_id - - user_id = action["user"]["id"] - user_email = action["user"]["email"] - - action_id = action["callback_id"] - - for f in dialog_action_functions(config, action_id): - background_tasks.add_task( - f, - user_id=user_id, - user_email=user_email, - config=config, - channel_id=channel_id, - incident_id=incident_id, - action=action, - ) - - db_session.close() - - -def handle_block_action( - config: SlackConversationConfiguration, action: dict, background_tasks: BackgroundTasks -): - """Handles a standalone block action.""" - organization_slug = None - if action.get("view"): - view_data = action["view"] - view_data["private_metadata"] = json.loads(view_data["private_metadata"]) - - incident_id = view_data["private_metadata"].get("incident_id") - channel_id = view_data["private_metadata"].get("channel_id") - action_id = action["actions"][0]["action_id"] - else: - try: - button = ButtonValue.parse_raw(action["actions"][0]["value"]) - organization_slug = button.organization_slug - incident_id = button.incident_id - except ValidationError: - organization_slug, incident_id = action["actions"][0]["value"].split("-") - - channel_id = action["channel"]["id"] - action_id = action["actions"][0]["action_id"] - - user_id = action["user"]["id"] - user_email = action["user"]["email"] - for f in block_action_functions(action_id): - background_tasks.add_task( - f, - user_id=user_id, - user_email=user_email, - channel_id=channel_id, - incident_id=incident_id, - config=config, - action=action, - organization_slug=organization_slug, - ) - - -@slack_background_task -def add_user_to_tactical_group( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Adds a user to the incident tactical group.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - if not incident: - message = "Sorry, we cannot subscribe you to this incident. It does not exist." - elif incident.visibility == Visibility.restricted: - message = f"Sorry, we cannot subscribe you to an incident with restricted visibility. Please, reach out to the incident commander ({incident.commander.individual.name}) if you have any questions." - else: - incident_flows.add_participant_to_tactical_group( - user_email=user_email, incident=incident, db_session=db_session - ) - message = f"Success! We've subscribed you to incident {incident.name}. You will receive all tactical reports about this incident via email." - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - - -@slack_background_task -def add_user_to_conversation( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Adds a user to a conversation.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - if not incident: - message = "Sorry, we cannot add you to this incident. It does not exist." - elif incident.visibility == Visibility.restricted: - message = f"Sorry, we cannot add you to an incident with restricted visibility. Please, reach out to the incident commander ({incident.commander.individual.name}) if you have any questions." - elif incident.status == IncidentStatus.closed: - message = f"Sorry, we cannot add you to an incident that has already been closed. Please, reach out to the incident commander ({incident.commander.individual.name}) for details." - else: - dispatch_slack_service.add_users_to_conversation( - slack_client, incident.conversation.channel_id, [user_id] - ) - message = f"Success! We've added you to incident {incident.name}. Please, check your Slack sidebar for the new incident channel." - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - - -@slack_background_task -def monitor_link( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Starts monitoring a link.""" - button = MonitorButton.parse_raw(action["actions"][0]["value"]) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - plugin_instance = plugin_service.get_instance( - db_session=db_session, plugin_instance_id=button.plugin_instance_id - ) - - creator_email = get_user_email(slack_client, action["user"]["id"]) - creator = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident.id, email=creator_email - ) - - if button.action_type == "monitor": - status = plugin_instance.instance.get_match_status(weblink=button.weblink) - - monitor_in = MonitorCreate( - incident=incident, - enabled=True, - status=status, - plugin_instance=plugin_instance, - creator=creator, - weblink=button.weblink, - ) - message_template = INCIDENT_MONITOR_CREATED_NOTIFICATION - - elif button.action_type == "ignore": - monitor_in = MonitorCreate( - incident=incident, - enabled=False, - plugin_instance=plugin_instance, - creator=creator, - weblink=button.weblink, - ) - - message_template = INCIDENT_MONITOR_IGNORE_NOTIFICATION - - else: - raise DispatchException(f"Unknown monitor action type. Type: {button.action_type}") - - monitor_service.create_or_update(db_session=db_session, monitor_in=monitor_in) - - notification_text = "Incident Notification" - notification_type = "incident-notification" - - plugin = plugin_service.get_active_instance( - db_session=db_session, plugin_type="conversation", project_id=incident.project.id - ) - plugin.instance.send_ephemeral( - channel_id, - user_id, - notification_text, - message_template, - notification_type, - weblink=button.weblink, - ) - - -@slack_background_task -def update_task_status( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Updates a task based on user input.""" - button = TaskButton.parse_raw(action["actions"][0]["value"]) - - resolve = True - if button.action_type == "reopen": - resolve = False - - # we only update the external task allowing syncing to care of propagation to dispatch - task = task_service.get_by_resource_id(db_session=db_session, resource_id=button.resource_id) - - # avoid external calls if we are already in the desired state - if resolve and task.status == TaskStatus.resolved: - message = "Task is already resolved." - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - return - - if not resolve and task.status == TaskStatus.open: - message = "Task is already open." - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - return - - # we don't currently have a good way to get the correct file_id (we don't store a task <-> relationship) - # lets try in both the incident doc and PIR doc - drive_task_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=task.incident.project.id, plugin_type="task" - ) - - try: - file_id = task.incident.incident_document.resource_id - drive_task_plugin.instance.update(file_id, button.resource_id, resolved=resolve) - except Exception: - file_id = task.incident.incident_review_document.resource_id - drive_task_plugin.instance.update(file_id, button.resource_id, resolved=resolve) - - status = "resolved" if task.status == TaskStatus.open else "re-opened" - message = f"Task successfully {status}." - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - - -@slack_background_task -def handle_engage_oncall_action( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Adds and pages based on the oncall modal.""" - oncall_service_external_id = action["submission"]["oncall_service_external_id"] - page = action["submission"]["page"] - - oncall_individual, oncall_service = incident_flows.incident_engage_oncall_flow( - user_email, incident_id, oncall_service_external_id, page=page, db_session=db_session - ) - - if not oncall_individual and not oncall_service: - message = "Could not engage oncall. Oncall service plugin not enabled." - - if not oncall_individual and oncall_service: - message = f"A member of {oncall_service.name} is already in the conversation." - - if oncall_individual and oncall_service: - message = f"You have successfully engaged {oncall_individual.name} from the {oncall_service.name} oncall rotation." - - dispatch_slack_service.send_ephemeral_message(slack_client, channel_id, user_id, message) - - -@slack_background_task -def handle_tactical_report_create( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Handles the creation of a tactical report.""" - tactical_report_in = TacticalReportCreate( - conditions=action["submission"]["conditions"], - actions=action["submission"]["actions"], - needs=action["submission"]["needs"], - ) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - report_flows.create_tactical_report( - user_email=user_email, - incident_id=incident_id, - tactical_report_in=tactical_report_in, - db_session=db_session, - ) - - # we let the user know that the report has been sent to the tactical group - send_feedack_to_user( - incident.conversation.channel_id, - incident.project.id, - user_id, - f"The tactical report has been emailed to the incident tactical group ({incident.tactical_group.email}).", - db_session, - ) - - -@slack_background_task -def handle_executive_report_create( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Handles the creation of executive reports.""" - executive_report_in = ExecutiveReportCreate( - current_status=action["submission"]["current_status"], - overview=action["submission"]["overview"], - next_steps=action["submission"]["next_steps"], - ) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - executive_report = report_flows.create_executive_report( - user_email=user_email, - incident_id=incident_id, - executive_report_in=executive_report_in, - db_session=db_session, - ) - - # we let the user know that the report has been created - send_feedack_to_user( - incident.conversation.channel_id, - incident.project.id, - user_id, - f"The executive report document has been created and can be found in the incident storage here: {executive_report.document.weblink}", - db_session, - ) - - # we let the user know that the report has been sent to the notifications group - send_feedack_to_user( - incident.conversation.channel_id, - incident.project.id, - user_id, - f"The executive report has been emailed to the incident notifications group ({incident.notifications_group.email}).", - db_session, - ) - - -@slack_background_task -def handle_assign_role_action( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Handles assign role actions.""" - assignee_user_id = action["submission"]["participant"] - assignee_role = action["submission"]["role"] - assignee_email = get_user_email(client=slack_client, user_id=assignee_user_id) - - # we assign the role - incident_flows.incident_assign_role_flow( - incident_id=incident_id, - assigner_email=user_email, - assignee_email=assignee_email, - assignee_role=assignee_role, - db_session=db_session, - ) - - if ( - assignee_role == ParticipantRoleType.reporter - or assignee_role == ParticipantRoleType.incident_commander - ): - # we update the external ticket - incident_flows.update_external_incident_ticket( - incident_id=incident_id, db_session=db_session - ) - - -def dialog_action_functions(config: SlackConfiguration, action: str): - """Interprets the action and routes it to the appropriate function.""" - action_mappings = { - config.slack_command_assign_role: [handle_assign_role_action], - config.slack_command_engage_oncall: [handle_engage_oncall_action], - config.slack_command_report_executive: [handle_executive_report_create], - config.slack_command_report_tactical: [handle_tactical_report_create], - } - - # this allows for unique action blocks e.g. invite-user or invite-user-1, etc - for key in action_mappings.keys(): - if key in action: - return action_mappings[key] - return [] diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py new file mode 100644 index 000000000000..f45039265b1c --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +import logging +from typing import Any + +from blockkit import Section, Modal +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.async_app import AsyncRespond +from slack_bolt.response import BoltResponse +from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler +from slack_sdk.web.async_client import AsyncWebClient + +from fastapi import APIRouter + +from starlette.requests import Request + +from .decorators import message_dispatcher +from .middleware import ( + message_context_middleware, + db_middleware, + user_middleware, + configuration_middleware, +) + + +app = AsyncApp( + token="xoxb-valid", raise_error_for_unhandled_request=True, process_before_response=True +) +router = APIRouter() + +logging.basicConfig(level=logging.DEBUG) + + +@app.error +async def app_error_handler( + error: Any, + client: AsyncWebClient, + body: dict, + logger: logging.Logger, + respond: AsyncRespond, +) -> BoltResponse: + + if body: + logger.info(f"Request body: {body}") + + if error: + logger.exception(f"Error: {error}") + + # the user is within a modal flow + if body.get("view"): + modal = Modal(title="Error", close="Close", blocks=[Section(text=str(error))]).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + # the user is in a message flow + if body.get("response_url"): + await respond(text=str(error), response_type="ephemeral") + + return BoltResponse(body=body, status=HTTPStatus.INTERNAL_SERVER_ERROR.value) + + +@app.event( + {"type": "message"}, + middleware=[ + message_context_middleware, + db_middleware, + user_middleware, + configuration_middleware, + ], +) +async def handle_message_events( + ack, payload, context, body, client, respond, user, db_session +) -> None: + """Container function for all message functions.""" + await message_dispatcher.dispatch(**locals()) + + +handler = AsyncSlackRequestHandler(app) + + +@router.post( + "/slack/event", +) +async def slack_events(request: Request): + """Handle all incoming Slack events.""" + return await handler.handle(request) + + +@router.post( + "/slack/command", +) +async def slack_commands(request: Request): + """Handle all incoming Slack commands.""" + return await handler.handle(request) + + +@router.post( + "/slack/action", +) +async def slack_actions(request: Request): + """Handle all incoming Slack actions.""" + return await handler.handle(request) + + +@router.post( + "/slack/menu", +) +async def slack_menus(request: Request): + """Handle all incoming Slack actions.""" + return await handler.handle(request) diff --git a/src/dispatch/plugins/dispatch_slack/commands.py b/src/dispatch/plugins/dispatch_slack/commands.py deleted file mode 100644 index ade011ff5edf..000000000000 --- a/src/dispatch/plugins/dispatch_slack/commands.py +++ /dev/null @@ -1,637 +0,0 @@ -import base64 -import logging -from typing import List -from pydantic.error_wrappers import ErrorWrapper, ValidationError -from pydantic import BaseModel - -from sqlalchemy.orm import Session - -from dispatch.exceptions import NotFoundError - -from dispatch.conversation import service as conversation_service -from dispatch.conversation.enums import ConversationButtonActions -from dispatch.database.core import resolve_attr -from dispatch.enums import Visibility -from dispatch.incident import service as incident_service -from dispatch.incident.enums import IncidentStatus -from dispatch.incident.messaging import send_incident_resources_ephemeral_message_to_participant -from dispatch.participant import service as participant_service -from dispatch.participant_role import service as participant_role_service -from dispatch.participant_role.models import ParticipantRoleType -from dispatch.plugin import service as plugin_service -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.plugins.dispatch_slack.models import TaskButton -from dispatch.project import service as project_service -from dispatch.task import service as task_service -from dispatch.task.enums import TaskStatus -from dispatch.task.models import Task - -from .config import SlackConfiguration, SlackConversationConfiguration - -from .decorators import ( - get_organization_scope_from_channel_id, - slack_background_task, -) - -from .dialogs import ( - create_assign_role_dialog, - create_engage_oncall_dialog, - create_executive_report_dialog, - create_tactical_report_dialog, -) - -from .messaging import ( - get_incident_conversation_command_message, - create_command_run_in_conversation_where_bot_not_present_message, - create_command_run_in_nonincident_conversation_message, - create_command_run_by_non_privileged_user_message, -) - -from .modals.incident.handlers import ( - create_add_timeline_event_modal, - create_report_incident_modal, - create_update_incident_modal, - create_update_notifications_group_modal, - create_update_participant_modal, -) - -from .modals.workflow.handlers import create_run_workflow_modal - - -log = logging.getLogger(__name__) - - -def base64_encode(input: str): - """Returns a b64 encoded string.""" - return base64.b64encode(input.encode("ascii")).decode("ascii") - - -def check_command_restrictions( - config: SlackConfiguration, command: str, user_email: str, incident_id: int, db_session: Session -) -> bool: - """Checks the current user's role to determine what commands they are allowed to run.""" - # some commands are sensitive and we only let non-participants execute them - command_permissions = { - config.slack_command_add_timeline_event: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.scribe, - ], - config.slack_command_assign_role: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.liaison, - ParticipantRoleType.scribe, - ParticipantRoleType.reporter, - ParticipantRoleType.participant, - ParticipantRoleType.observer, - ], - config.slack_command_report_executive: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.scribe, - ], - config.slack_command_report_tactical: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.scribe, - ], - config.slack_command_update_incident: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.scribe, - ], - config.slack_command_update_notifications_group: [ - ParticipantRoleType.incident_commander, - ParticipantRoleType.scribe, - ], - } - - # no permissions have been defined - if command not in command_permissions.keys(): - return True - - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - - # if any required role is active, allow command - for active_role in participant.active_roles: - for allowed_role in command_permissions[command]: - if active_role.role == allowed_role: - return True - - -def command_functions(config: SlackConfiguration, command: str): - """Interprets the command and routes it the appropriate function.""" - command_mappings = { - config.slack_command_add_timeline_event: [create_add_timeline_event_modal], - config.slack_command_assign_role: [create_assign_role_dialog], - config.slack_command_engage_oncall: [create_engage_oncall_dialog], - config.slack_command_list_incidents: [list_incidents], - config.slack_command_list_my_tasks: [list_my_tasks], - config.slack_command_list_participants: [list_participants], - config.slack_command_list_resources: [list_resources], - config.slack_command_list_tasks: [list_tasks], - config.slack_command_list_workflows: [list_workflows], - config.slack_command_report_executive: [create_executive_report_dialog], - config.slack_command_report_incident: [create_report_incident_modal], - config.slack_command_report_tactical: [create_tactical_report_dialog], - config.slack_command_run_workflow: [create_run_workflow_modal], - config.slack_command_update_incident: [create_update_incident_modal], - config.slack_command_update_notifications_group: [create_update_notifications_group_modal], - config.slack_command_update_participant: [create_update_participant_modal], - } - - return command_mappings.get(command, []) - - -def filter_tasks_by_assignee_and_creator(tasks: List[Task], by_assignee: str, by_creator: str): - """Filters a list of tasks looking for a given creator or assignee.""" - filtered_tasks = [] - for t in tasks: - if by_creator: - creator_email = t.creator.individual.email - if creator_email == by_creator: - filtered_tasks.append(t) - # lets avoid duplication if creator is also assignee - continue - - if by_assignee: - assignee_emails = [a.individual.email for a in t.assignees] - if by_assignee in assignee_emails: - filtered_tasks.append(t) - - return filtered_tasks - - -async def handle_non_incident_conversation_commands(config, client, request, background_tasks): - """Handles all commands that do not have a specific incident conversation.""" - command = request.get("command") - channel_id = request.get("channel_id") - command_args = request.get("text", "").split(" ") - if command_args: - organization_slug = command_args[0] - - # We get the list of public and private conversations the Dispatch bot is a member of - ( - public_conversations, - private_conversations, - ) = await dispatch_slack_service.get_conversations_by_user_id_async( - client, config.app_user_slug - ) - - # We get the name of conversation where the command was run - conversation_name = await dispatch_slack_service.get_conversation_name_by_id_async( - client, channel_id - ) - - if ( - not conversation_name - or conversation_name not in public_conversations + private_conversations - ): - # We let the user know in which public conversations they can run the command - return create_command_run_in_conversation_where_bot_not_present_message( - command, public_conversations - ) - - user_id = request.get("user_id") - user_email = await dispatch_slack_service.get_user_email_async(client, user_id) - - for f in command_functions(config, command): - background_tasks.add_task( - f, - user_id=user_id, - user_email=user_email, - channel_id=channel_id, - config=config, - incident_id=None, - organization_slug=organization_slug, - command=request, - ) - - return get_incident_conversation_command_message(config, command) - - -async def handle_incident_conversation_commands(config, client, request, background_tasks): - """Handles all commands that are issued from an incident conversation.""" - channel_id = request.get("channel_id") - command = request.get("command") - db_session = get_organization_scope_from_channel_id(channel_id=channel_id) - - if not db_session: - # We let the user know that incident-specific commands - # can only be run in incident conversations - return create_command_run_in_nonincident_conversation_message(command) - - conversation = conversation_service.get_by_channel_id_ignoring_channel_type( - db_session=db_session, channel_id=channel_id - ) - - user_id = request.get("user_id") - user_email = await dispatch_slack_service.get_user_email_async(client, user_id) - - # some commands are sensitive and we only let non-participants execute them - allowed = check_command_restrictions( - config=config, - command=command, - user_email=user_email, - incident_id=conversation.incident.id, - db_session=db_session, - ) - if not allowed: - return create_command_run_by_non_privileged_user_message(command) - - for f in command_functions(config, command): - background_tasks.add_task( - f, - user_id=user_id, - user_email=user_email, - channel_id=channel_id, - config=config, - incident_id=conversation.incident.id, - command=request, - ) - - db_session.close() - - return get_incident_conversation_command_message(config, command) - - -async def handle_slack_command(*, config, client, request, background_tasks): - """Handles slack command message.""" - # We get the name of command that was run - command = request.get("command") - if command in [config.slack_command_report_incident, config.slack_command_list_incidents]: - return await handle_non_incident_conversation_commands( - config, client, request, background_tasks - ) - else: - return await handle_incident_conversation_commands( - config, client, request, background_tasks - ) - - -@slack_background_task -def list_resources( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Runs the list incident resources flow.""" - # we load the incident instance - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - # we send the list of resources to the participant - send_incident_resources_ephemeral_message_to_participant( - command["user_id"], incident, db_session - ) - - -@slack_background_task -def list_my_tasks( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Returns the list of incident tasks to the user as an ephemeral message.""" - list_tasks( - user_id=user_id, - user_email=user_email, - channel_id=channel_id, - incident_id=incident_id, - config=config, - command=command, - by_creator=user_email, - by_assignee=user_email, - db_session=db_session, - slack_client=slack_client, - ) - - -@slack_background_task -def list_tasks( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, - by_creator: str = None, - by_assignee: str = None, -): - """Returns the list of incident tasks to the user as an ephemeral message.""" - blocks = [] - - for status in TaskStatus: - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*{status} Incident Tasks*"}, - } - ) - button_text = "Resolve" if status == TaskStatus.open else "Re-open" - action_type = "resolve" if status == TaskStatus.open else "reopen" - - tasks = task_service.get_all_by_incident_id_and_status( - db_session=db_session, incident_id=incident_id, status=status - ) - - if by_creator or by_assignee: - tasks = filter_tasks_by_assignee_and_creator(tasks, by_assignee, by_creator) - - for idx, task in enumerate(tasks): - assignees = [f"<{a.individual.weblink}|{a.individual.name}>" for a in task.assignees] - - task_button = TaskButton( - organization_slug=task.project.organization.slug, - action_type=action_type, - incident_id=incident_id, - resource_id=task.resource_id, - ) - - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*Description:* <{task.weblink}|{task.description}>\n" - f"*Creator:* <{task.creator.individual.weblink}|{task.creator.individual.name}>\n" - f"*Assignees:* {', '.join(assignees)}" - ), - }, - "block_id": f"{ConversationButtonActions.update_task_status}-{task.status}-{idx}", - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": button_text}, - "value": task_button.json(), - "action_id": f"{ConversationButtonActions.update_task_status}", - }, - } - ) - blocks.append({"type": "divider"}) - - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - "Incident Task List", - blocks=blocks, - ) - - -@slack_background_task -def list_workflows( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Returns the list of incident workflows to the user as an ephemeral message.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - blocks = [] - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Incident Workflows*"}}) - for w in incident.workflow_instances: - artifact_links = "" - for a in w.artifacts: - artifact_links += f"- <{a.weblink}|{a.name}> \n" - - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*Name:* <{w.weblink}|{w.workflow.name}>\n" - f"*Workflow Description:* {w.workflow.description}\n" - f"*Run Reason:* {w.run_reason}\n" - f"*Creator:* {w.creator.individual.name}\n" - f"*Status:* {w.status}\n" - f"*Artifacts:* \n {artifact_links}" - ), - }, - } - ) - blocks.append({"type": "divider"}) - - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - "Incident Workflow List", - blocks=blocks, - ) - - -@slack_background_task -def list_participants( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Returns the list of incident participants to the user as an ephemeral message.""" - blocks = [] - blocks.append( - {"type": "section", "text": {"type": "mrkdwn", "text": "*Incident Participants*"}} - ) - - participants = participant_service.get_all_by_incident_id( - db_session=db_session, incident_id=incident_id - ).all() - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - contact_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="contact" - ) - - for participant in participants: - if participant.active_roles: - participant_email = participant.individual.email - participant_info = contact_plugin.instance.get(participant_email, db_session=db_session) - participant_name = participant_info.get("fullname", participant.individual.email) - participant_team = participant_info.get("team", "Unknown") - participant_department = participant_info.get("department", "Unknown") - participant_location = participant_info.get("location", "Unknown") - participant_weblink = participant_info.get("weblink") - participant_avatar_url = dispatch_slack_service.get_user_avatar_url( - slack_client, participant_email - ) - - participant_reason_added = participant.added_reason or "Unknown" - if participant.added_by: - participant_added_by = participant.added_by.individual.name - else: - participant_added_by = "Unknown" - - participant_active_roles = participant_role_service.get_all_active_roles( - db_session=db_session, participant_id=participant.id - ) - participant_roles = [] - for role in participant_active_roles: - participant_roles.append(role.role) - - # TODO we should make this more resilient to missing data (kglisson) - block = { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*Name:* <{participant_weblink}|{participant_name} ({participant_email})>\n" - f"*Team*: {participant_team}, {participant_department}\n" - f"*Location*: {participant_location}\n" - f"*Incident Role(s)*: {(', ').join(participant_roles)}\n" - f"*Reason Added*: {participant_reason_added}\n" - f"*Added By*: {participant_added_by}\n" - ), - }, - } - - if len(participants) < 20: - block.update( - { - "accessory": { - "type": "image", - "alt_text": participant_name, - "image_url": participant_avatar_url, - }, - } - ) - - blocks.append(block) - blocks.append({"type": "divider"}) - - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - "Incident Participant List", - blocks=blocks, - ) - - -@slack_background_task -def list_incidents( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Returns the list of current active and stable incidents, - and closed incidents in the last 24 hours.""" - projects = [] - incidents = [] - args = command["text"].split(" ") - - # scopes reply to the current incident's project - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - if incident: - # command was run in an incident conversation - projects.append(incident.project) - else: - # command was run in a non-incident conversation - if len(args) == 2: - project = project_service.get_by_name(db_session=db_session, name=args[1]) - - if project: - projects.append() - else: - raise ValidationError( - [ - ErrorWrapper( - NotFoundError( - msg=f"Project name '{args[1]}' in organization '{args[0]}' not found. Check your spelling." - ), - loc="project", - ) - ], - model=BaseModel, - ) - - else: - projects = project_service.get_all(db_session=db_session) - - for project in projects: - # we fetch active incidents - incidents.extend( - incident_service.get_all_by_status( - db_session=db_session, project_id=project.id, status=IncidentStatus.active - ) - ) - # We fetch stable incidents - incidents.extend( - incident_service.get_all_by_status( - db_session=db_session, - project_id=project.id, - status=IncidentStatus.stable, - ) - ) - # We fetch closed incidents in the last 24 hours - incidents.extend( - incident_service.get_all_last_x_hours_by_status( - db_session=db_session, - project_id=project.id, - status=IncidentStatus.closed, - hours=24, - ) - ) - - blocks = [] - blocks.append({"type": "header", "text": {"type": "plain_text", "text": "List of Incidents"}}) - - if incidents: - for incident in incidents: - if incident.visibility == Visibility.open: - ticket_weblink = resolve_attr(incident, "ticket.weblink") - try: - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*<{ticket_weblink}|{incident.name}>*\n" - f"*Title*: {incident.title}\n" - f"*Type*: {incident.incident_type.name}\n" - f"*Severity*: {incident.incident_severity.name}\n" - f"*Priority*: {incident.incident_priority.name}\n" - f"*Status*: {incident.status}\n" - f"*Incident Commander*: <{incident.commander.individual.weblink}|{incident.commander.individual.name}>\n" - f"*Project*: {incident.project.name}" - ), - }, - } - ) - except Exception as e: - log.exception(e) - - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - "Incident List", - blocks=blocks, - ) diff --git a/src/dispatch/plugins/dispatch_slack/decorators.py b/src/dispatch/plugins/dispatch_slack/decorators.py index 8b1c04e69bb5..cc7e753deaa1 100644 --- a/src/dispatch/plugins/dispatch_slack/decorators.py +++ b/src/dispatch/plugins/dispatch_slack/decorators.py @@ -1,185 +1,39 @@ -from functools import wraps -import inspect import logging -import time -import uuid - -from pydantic.error_wrappers import ErrorWrapper, ValidationError -from pydantic import BaseModel - -from dispatch.exceptions import NotFoundError -from dispatch.conversation import service as conversation_service -from dispatch.database.core import engine, sessionmaker, SessionLocal -from dispatch.metrics import provider as metrics_provider -from dispatch.organization import service as organization_service -from dispatch.plugin import service as plugin_service -from dispatch.plugins.base.v1 import Plugin -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service - - -log = logging.getLogger(__name__) - - -def get_plugin_configuration_from_channel_id(db_session: SessionLocal, channel_id: str) -> Plugin: - """Fetches the currently slack plugin configuration for this incident channel.""" - conversation = conversation_service.get_by_channel_id_ignoring_channel_type( - db_session, channel_id - ) - if conversation: - plugin_instance = plugin_service.get_active_instance( - db_session=db_session, - plugin_type="conversation", - project_id=conversation.incident.project.id, - ) - return plugin_instance.configuration - - -# we need a way to determine which organization to use for a given -# event, we use the unique channel id to determine which organization the -# event belongs to. -def get_organization_scope_from_channel_id(channel_id: str) -> SessionLocal: - """Iterate all organizations looking for a relevant channel_id.""" - db_session = SessionLocal() - organization_slugs = [o.slug for o in organization_service.get_all(db_session=db_session)] - db_session.close() - - for slug in organization_slugs: - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{slug}", - } - ) - - scoped_db_session = sessionmaker(bind=schema_engine)() - conversation = conversation_service.get_by_channel_id_ignoring_channel_type( - db_session=scoped_db_session, channel_id=channel_id - ) - if conversation: - return scoped_db_session - - scoped_db_session.close() - - -def get_organization_scope_from_slug(slug: str) -> SessionLocal: - """Iterate all organizations looking for a matching slug.""" - db_session = SessionLocal() - organization = organization_service.get_by_slug(db_session=db_session, slug=slug) - db_session.close() - - if organization: - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{slug}", - } - ) - - return sessionmaker(bind=schema_engine)() - - raise ValidationError( - [ - ErrorWrapper( - NotFoundError(msg=f"Organization slug '{slug}' not found. Check your spelling."), - loc="organization", - ) - ], - model=BaseModel, - ) - - -def get_default_organization_scope() -> str: - """Iterate all organizations looking for matching organization.""" - db_session = SessionLocal() - organization = organization_service.get_default(db_session=db_session) - db_session.close() - - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{organization.slug}", - } - ) - - return sessionmaker(bind=schema_engine)() - - -def fullname(o): - module = inspect.getmodule(o) - return f"{module.__name__}.{o.__qualname__}" - +import inspect -def slack_background_task(func): - """Decorator that sets up the a slack background task function - with a database session and exception tracking. +log = logging.getLogger(__file__) - As background tasks run in their own threads, it does not attempt - to propagate errors. - """ - @wraps(func) - def wrapper(*args, **kwargs): - background = False +class MessageDispatcher: + """Dispatches current message to any registered function.""" - metrics_provider.counter( - "function.call.counter", tags={"function": fullname(func), "slack": True} - ) + registered_funcs = [] - channel_id = kwargs["channel_id"] - user_id = kwargs["user_id"] - if not kwargs.get("db_session"): + def add(self, *args, **kwargs): + """Adds a function to the dispatcher.""" - # slug passed directly is prefered over just having a channel_id - organization_slug = kwargs.pop("organization_slug", None) - if not organization_slug: - scoped_db_session = get_organization_scope_from_channel_id(channel_id=channel_id) - if not scoped_db_session: - scoped_db_session = get_default_organization_scope() + def decorator(func): + if not kwargs.get("name"): + name = func.__name__ else: - scoped_db_session = get_organization_scope_from_slug(organization_slug) - - background = True - kwargs["db_session"] = scoped_db_session - - if not kwargs.get("slack_client"): - slack_client = dispatch_slack_service.create_slack_client(config=kwargs["config"]) - kwargs["slack_client"] = slack_client + name = kwargs.pop("name") - try: - start = time.perf_counter() - result = func(*args, **kwargs) - elapsed_time = time.perf_counter() - start - metrics_provider.timer( - "function.elapsed.time", - value=elapsed_time, - tags={"function": fullname(func), "slack": True}, - ) - return result - except ValidationError as e: - log.exception(e) - message = f"Command Error: {e.errors()[0]['msg']}" + self.registered_funcs.append({"name": name, "func": func}) - dispatch_slack_service.send_ephemeral_message( - client=kwargs["slack_client"], - conversation_id=channel_id, - user_id=user_id, - text=message, - ) + return decorator - except Exception as e: - # we generate our own guid for now, maybe slack provides us something we can use? - slack_interaction_guid = str(uuid.uuid4()) - log.exception(e, extra=dict(slack_interaction_guid=slack_interaction_guid)) + async def dispatch(self, *args, **kwargs): + """Runs all registered functions.""" + for f in self.registered_funcs: + # only inject the args the function cares about + func_args = inspect.getfullargspec(inspect.unwrap(f["func"])).args + injected_args = (kwargs[a] for a in func_args) - # we notify the user that the interaction failed - message = f"""Sorry, we've run into an unexpected error. For help please reach out to your Dispatch admins \ -and provide them with the following token: '{slack_interaction_guid}'.""" + try: + await f["func"](*injected_args) + except Exception as e: + log.exception(e) + log.debug(f"Failed to run dispatched function ({e})") - dispatch_slack_service.send_ephemeral_message( - client=kwargs["slack_client"], - conversation_id=channel_id, - user_id=user_id, - text=message, - ) - finally: - if background: - kwargs["db_session"].close() - return wrapper +message_dispatcher = MessageDispatcher() diff --git a/src/dispatch/plugins/dispatch_slack/dialogs.py b/src/dispatch/plugins/dispatch_slack/dialogs.py deleted file mode 100644 index 9d8b66bf5f7d..000000000000 --- a/src/dispatch/plugins/dispatch_slack/dialogs.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging - -from dispatch.incident import service as incident_service -from dispatch.participant_role.models import ParticipantRoleType -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration -from dispatch.plugins.dispatch_slack.decorators import slack_background_task -from dispatch.report import service as report_service -from dispatch.report.enums import ReportTypes -from dispatch.service import service as service_service - - -log = logging.getLogger(__name__) - - -@slack_background_task -def create_assign_role_dialog( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a dialog for assigning a role.""" - role_options = [] - for role in ParticipantRoleType: - if role != ParticipantRoleType.participant: - role_options.append({"label": role, "value": role}) - - dialog = { - "callback_id": command["command"], - "title": "Assign Role", - "submit_label": "Assign", - "elements": [ - { - "label": "Participant", - "type": "select", - "name": "participant", - "data_source": "users", - }, - {"label": "Role", "type": "select", "name": "role", "options": role_options}, - ], - } - - dispatch_slack_service.open_dialog_with_user(slack_client, command["trigger_id"], dialog) - - -@slack_background_task -def create_engage_oncall_dialog( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a dialog to engage an oncall person.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - oncall_services = service_service.get_all_by_project_id_and_status( - db_session=db_session, project_id=incident.project.id, is_active=True - ) - - if not oncall_services.count(): - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "No oncall services have been defined. You can define them in the Dispatch UI at /services", - }, - } - ] - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - "No oncall services defined", - blocks=blocks, - ) - return - - oncall_service_options = [] - for oncall_service in oncall_services: - oncall_service_options.append( - {"label": oncall_service.name, "value": oncall_service.external_id} - ) - - page_options = [{"label": "Yes", "value": "Yes"}, {"label": "No", "value": "No"}] - dialog = { - "callback_id": command["command"], - "title": "Engage Oncall", - "submit_label": "Engage", - "elements": [ - { - "label": "Oncall Service", - "type": "select", - "name": "oncall_service_external_id", - "options": oncall_service_options, - }, - { - "label": "Page", - "type": "select", - "name": "page", - "value": "No", - "options": page_options, - }, - ], - } - - dispatch_slack_service.open_dialog_with_user(slack_client, command["trigger_id"], dialog) - - -@slack_background_task -def create_tactical_report_dialog( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a dialog with the most recent tactical report data, if it exists.""" - # we load the most recent tactical report - tactical_report = report_service.get_most_recent_by_incident_id_and_type( - db_session=db_session, incident_id=incident_id, report_type=ReportTypes.tactical_report - ) - - conditions = actions = needs = "" - if tactical_report: - conditions = tactical_report.details.get("conditions") - actions = tactical_report.details.get("actions") - needs = tactical_report.details.get("needs") - - dialog = { - "callback_id": command["command"], - "title": "Tactical Report", - "submit_label": "Submit", - "elements": [ - {"type": "textarea", "label": "Conditions", "name": "conditions", "value": conditions}, - {"type": "textarea", "label": "Actions", "name": "actions", "value": actions}, - {"type": "textarea", "label": "Needs", "name": "needs", "value": needs}, - ], - } - - dispatch_slack_service.open_dialog_with_user(slack_client, command["trigger_id"], dialog) - - -@slack_background_task -def create_executive_report_dialog( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a dialog with the most recent executive report data, if it exists.""" - # we load the most recent executive report - executive_report = report_service.get_most_recent_by_incident_id_and_type( - db_session=db_session, incident_id=incident_id, report_type=ReportTypes.executive_report - ) - - current_status = overview = next_steps = "" - if executive_report: - current_status = executive_report.details.get("current_status") - overview = executive_report.details.get("overview") - next_steps = executive_report.details.get("next_steps") - - dialog = { - "callback_id": command["command"], - "title": "Executive Report", - "submit_label": "Submit", - "elements": [ - { - "type": "textarea", - "label": "Current Status", - "name": "current_status", - "value": current_status, - }, - {"type": "textarea", "label": "Overview", "name": "overview", "value": overview}, - { - "type": "textarea", - "label": "Next Steps", - "name": "next_steps", - "value": next_steps, - "hint": f"Use {config.slack_command_update_notifications_group} to update the list of recipients of this report.", - }, - ], - } - - dispatch_slack_service.open_dialog_with_user(slack_client, command["trigger_id"], dialog) diff --git a/src/dispatch/plugins/dispatch_slack/events.py b/src/dispatch/plugins/dispatch_slack/events.py deleted file mode 100644 index c16464bef776..000000000000 --- a/src/dispatch/plugins/dispatch_slack/events.py +++ /dev/null @@ -1,541 +0,0 @@ -import pytz -import logging -import datetime - -from typing import List -from pydantic import BaseModel - -from sqlalchemy import func - -from dispatch.conversation import service as conversation_service -from dispatch.conversation.enums import ConversationButtonActions -from dispatch.event import service as event_service -from dispatch.incident import flows as incident_flows -from dispatch.incident import service as incident_service -from dispatch.individual import service as individual_service -from dispatch.monitor import service as monitor_service -from dispatch.nlp import build_phrase_matcher, build_term_vocab, extract_terms_from_text -from dispatch.participant import service as participant_service -from dispatch.participant_role import service as participant_role_service -from dispatch.participant_role.models import ParticipantRoleType -from dispatch.plugin import service as plugin_service -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration -from dispatch.tag import service as tag_service -from dispatch.tag.models import Tag - -from .decorators import slack_background_task, get_organization_scope_from_channel_id -from .models import MonitorButton -from .service import get_user_email - - -log = logging.getLogger(__name__) - - -class EventBodyItem(BaseModel): - """Body item of the Slack event.""" - - type: str = None - channel: str = None - ts: str = None - - -class EventBody(BaseModel): - """Body of the Slack event.""" - - channel: str = None - channel_id: str = None - channel_type: str = None - deleted_ts: str = None - event_ts: str = None - thread_ts: str = None - file_id: str = None - hidden: bool = None - inviter: str = None - item: EventBodyItem = None - item_user: str = None - reaction: str = None - subtype: str = None - team: str = None - text: str = None - type: str - user: str = None - user_id: str = None - - -class EventEnvelope(BaseModel): - """Envelope of the Slack event.""" - - api_app_id: str = None - authed_users: List[str] = [] - challenge: str = None - enterprise_id: str = None - event: EventBody = None - event_id: str = None - event_time: int = None - team_id: str = None - token: str = None - type: str - - -def get_channel_id_from_event(event: EventEnvelope): - """Returns the channel id from the Slack event.""" - channel_id = "" - if event.event.channel_id: - return event.event.channel_id - if event.event.channel: - return event.event.channel - if event.event.item.channel: - return event.event.item.channel - return channel_id - - -def event_functions(event: EventEnvelope): - """Interprets the events and routes it the appropriate function.""" - event_mappings = { - "member_joined_channel": [member_joined_channel], - "member_left_channel": [member_left_channel], - "message": [ - after_hours_message, - ban_threads_warning, - increment_participant_role_activity, - assess_participant_role_change, - message_monitor, - message_tagging, - ], - "message.groups": [], - "message.im": [], - "reaction_added": [handle_reaction_added_event], - } - return event_mappings.get(event.event.type, []) - - -async def handle_slack_event(*, config, client, event, background_tasks): - """Handles slack event message.""" - user_id = event.event.user - channel_id = get_channel_id_from_event(event) - - if user_id and channel_id: - db_session = get_organization_scope_from_channel_id(channel_id=channel_id) - - if not db_session: - log.info( - f"Unable to determine organization associated with channel id. ChannelId: {channel_id}" - ) - return {"ok": ""} - - conversation = conversation_service.get_by_channel_id_ignoring_channel_type( - db_session=db_session, channel_id=channel_id - ) - - if conversation and dispatch_slack_service.is_user(config, user_id): - # We resolve the user's email - user_email = await dispatch_slack_service.get_user_email_async(client, user_id) - # Dispatch event functions to be executed in the background - for f in event_functions(event): - background_tasks.add_task( - f, - config=config, - user_id=user_id, - user_email=user_email, - channel_id=channel_id, - incident_id=conversation.incident_id, - event=event, - ) - - db_session.close() - - return {"ok": ""} - - -@slack_background_task -def handle_reaction_added_event( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Handles an event where a reaction is added to a message.""" - reaction = event.event.reaction - - if reaction == config.timeline_event_reaction: - conversation_id = event.event.item.channel - message_ts = event.event.item.ts - message_ts_utc = datetime.datetime.utcfromtimestamp(float(message_ts)) - - # we fetch the message information - response = dispatch_slack_service.list_conversation_messages( - slack_client, conversation_id, latest=message_ts, limit=1, inclusive=1 - ) - message_text = response["messages"][0]["text"] - message_sender_id = response["messages"][0]["user"] - - # we fetch the incident - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - # we fetch the individual who sent the message - message_sender_email = get_user_email(client=slack_client, user_id=message_sender_id) - individual = individual_service.get_by_email_and_project( - db_session=db_session, email=message_sender_email, project_id=incident.project.id - ) - - # we log the event - event_service.log_incident_event( - db_session=db_session, - source="Slack Plugin - Conversation Management", - description=f'"{message_text}," said {individual.name}', - incident_id=incident_id, - individual_id=individual.id, - started_at=message_ts_utc, - ) - - -@slack_background_task -def increment_participant_role_activity( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Increments the participant role's activity counter.""" - if event.event.subtype == "channel_join" or event.event.subtype == "channel_leave": - # we don't increment the counter for channel_join or channel_leave messages - return - - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - - if participant: - active_participant_roles = participant.active_roles - for participant_role in active_participant_roles: - participant_role.activity += 1 - - db_session.commit() - - -@slack_background_task -def assess_participant_role_change( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Assesses the need of changing a participant's role based on its activity and changes it if needed.""" - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - - if participant: - for participant_role in participant.active_roles: - if participant_role.role == ParticipantRoleType.observer: - if participant_role.activity >= 10: # ten messages sent to the incident channel - # we change the participant's role to the participant one - participant_role_service.renounce_role( - db_session=db_session, participant_role=participant_role - ) - participant_role_service.add_role( - db_session=db_session, - participant_id=participant.id, - participant_role=ParticipantRoleType.participant, - ) - - # we log the event - event_service.log_incident_event( - db_session=db_session, - source="Slack Plugin - Conversation Management", - description=( - f"{participant.individual.name}'s role changed from {participant_role.role} to " - f"{ParticipantRoleType.participant} due to activity in the incident channel" - ), - incident_id=incident_id, - ) - break - - -def is_business_hours(commander_tz: str): - """Determines if it's currently office hours where the incident commander is located.""" - now = datetime.datetime.now(pytz.timezone(commander_tz)) - return now.weekday() not in [5, 6] and 9 <= now.hour < 17 - - -@slack_background_task -def after_hours_message( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Notifies the user that this incident is current in after hours mode.""" - # we ignore user channel and group join messages - if event.event.subtype in ["channel_join", "group_join"]: - return - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - # get their timezone from slack - commander_info = dispatch_slack_service.get_user_info_by_email( - slack_client, email=incident.commander.individual.email - ) - - commander_tz = commander_info["tz"] - - if not is_business_hours(commander_tz): - # send ephermal message - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - ( - f"Responses may be delayed. The current incident priority is *{incident.incident_priority.name}*" - f" and your message was sent outside of the Incident Commander's working hours (Weekdays, 9am-5pm, {commander_tz} timezone)." - ) - ), - }, - } - ] - - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - if not participant.after_hours_notification: - dispatch_slack_service.send_ephemeral_message( - slack_client, channel_id, user_id, "", blocks=blocks - ) - participant.after_hours_notification = True - db_session.add(participant) - db_session.commit() - - -@slack_background_task -def member_joined_channel( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope, - db_session=None, - slack_client=None, -): - """Handles the member_joined_channel Slack event.""" - participant = incident_flows.incident_add_or_reactivate_participant_flow( - user_email=user_email, incident_id=incident_id, db_session=db_session - ) - - if event.event.inviter: - # we update the participant's metadata - if not dispatch_slack_service.is_user(config, event.event.inviter): - # we default to the incident commander when we don't know who added the user - added_by_participant = participant_service.get_by_incident_id_and_role( - db_session=db_session, - incident_id=incident_id, - role=ParticipantRoleType.incident_commander, - ) - participant.added_by = added_by_participant - participant.added_reason = ( - f"Participant added by {added_by_participant.individual.name}" - ) - - else: - inviter_email = get_user_email(client=slack_client, user_id=event.event.inviter) - added_by_participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=inviter_email - ) - participant.added_by = added_by_participant - participant.added_reason = event.event.text - - db_session.add(participant) - db_session.commit() - - -@slack_background_task -def member_left_channel( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope, - db_session=None, - slack_client=None, -): - """Handles the member_left_channel Slack event.""" - incident_flows.incident_remove_participant_flow(user_email, incident_id, db_session=db_session) - - -@slack_background_task -def ban_threads_warning( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Sends the user an ephemeral message if they use threads.""" - if not config.ban_threads: - return - - if event.event.thread_ts: - # we should be able to look for `subtype == message_replied` once this bug is fixed - # https://api.slack.com/events/message/message_replied - # From Slack: Bug alert! This event is missing the subtype field when dispatched - # over the Events API. Until it is fixed, examine message events' thread_ts value. - # When present, it's a reply. To be doubly sure, compare a thread_ts to the top-level ts - # value, when they differ the latter is a reply to the former. - message = "Please refrain from using threads in incident related channels. Threads make it harder for incident participants to maintain context." - dispatch_slack_service.send_ephemeral_message( - slack_client, - channel_id, - user_id, - message, - thread_ts=event.event.thread_ts, - ) - - -@slack_background_task -def message_tagging( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Looks for incident tags in incident messages.""" - text = event.event.text - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - tags = tag_service.get_all(db_session=db_session, project_id=incident.project.id).all() - tag_strings = [t.name.lower() for t in tags if t.discoverable] - phrases = build_term_vocab(tag_strings) - matcher = build_phrase_matcher("dispatch-tag", phrases) - extracted_tags = list(set(extract_terms_from_text(text, matcher))) - - matched_tags = ( - db_session.query(Tag) - .filter(func.upper(Tag.name).in_([func.upper(t) for t in extracted_tags])) - .all() - ) - - incident.tags.extend(matched_tags) - db_session.commit() - - -@slack_background_task -def message_monitor( - config: SlackConversationConfiguration, - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - event: EventEnvelope = None, - db_session=None, - slack_client=None, -): - """Looks strings that are available for monitoring (usually links).""" - text = event.event.text - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - plugins = plugin_service.get_active_instances( - db_session=db_session, project_id=incident.project.id, plugin_type="monitor" - ) - - for p in plugins: - for matcher in p.instance.get_matchers(): - for match in matcher.finditer(text): - match_data = match.groupdict() - monitor = monitor_service.get_by_weblink( - db_session=db_session, weblink=match_data["weblink"] - ) - - # silence ignored matches - if monitor: - continue - - current_status = p.instance.get_match_status(match_data) - if current_status: - status_text = "" - for k, v in current_status.items(): - status_text += f"*{k.title()}*:\n{v.title()}\n" - - monitor_button = MonitorButton( - incident_id=incident.id, - plugin_instance_id=p.id, - organization=incident.project.organization.slug, - weblink=match_data["weblink"], - action_type="monitor", - ) - - ignore_button = MonitorButton( - incident_id=incident.id, - plugin_instance_id=p.id, - organization=incident.project.organization.slug, - weblink=match_data["weblink"], - action_type="ignore", - ) - - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Hi! Dispatch is able to help track the status of: \n {match_data['weblink']} \n\n Would you like for changes in it's status to be propagated to this incident channel?", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": status_text, - }, - }, - { - "type": "actions", - "block_id": f"{ConversationButtonActions.monitor_link}", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "emoji": True, - "text": "Monitor", - }, - "style": "primary", - "value": monitor_button.json(), - }, - { - "type": "button", - "text": {"type": "plain_text", "emoji": True, "text": "Ignore"}, - "style": "danger", - "value": ignore_button.json(), - }, - ], - }, - ] - - dispatch_slack_service.send_ephemeral_message( - slack_client, channel_id, user_id, "", blocks=blocks - ) diff --git a/src/dispatch/plugins/dispatch_slack/exceptions.py b/src/dispatch/plugins/dispatch_slack/exceptions.py new file mode 100644 index 000000000000..31165426db1b --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/exceptions.py @@ -0,0 +1,21 @@ +from dispatch.exceptions import DispatchException + + +class CommandDispatchError(DispatchException): + code = "command" + msg_template = "{msg}" + + +class SubmissionError(DispatchException): + code = "submission" + msg_template = "{msg}" + + +class ContextError(DispatchException): + code = "context" + msg_template = "{msg}" + + +class RoleError(DispatchException): + code = "role" + msg_template = "{msg}" diff --git a/src/dispatch/plugins/dispatch_slack/feedback.py b/src/dispatch/plugins/dispatch_slack/feedback.py new file mode 100644 index 000000000000..5e682eb1ba52 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/feedback.py @@ -0,0 +1,159 @@ +from blockkit import Checkboxes, Context, Input, MarkdownText, Modal, PlainTextInput + +from dispatch.enums import DispatchEnum +from dispatch.feedback import service as feedback_service +from dispatch.feedback.enums import FeedbackRating +from dispatch.feedback.models import FeedbackCreate +from dispatch.incident import service as incident_service +from dispatch.participant import service as participant_service + +from .bolt import app +from .fields import static_select_block +from .middleware import ( + action_context_middleware, + button_context_middleware, + db_middleware, + modal_submit_middleware, + user_middleware, +) + + +class FeedbackNotificationBlockIds(DispatchEnum): + feedback_input = "feedback-notification-feedback-input" + rating_select = "feedback-notification-rating-select" + anonymous_checkbox = "feedback-notification-anonymous-checkbox" + + +class FeedbackNotificationActionIds(DispatchEnum): + feedback_input = "feedback-notification-feedback-input" + rating_select = "feedback-notification-rating-select" + anonymous_checkbox = "feedback-notification-anonymous-checkbox" + + +class FeedbackNotificationActions(DispatchEnum): + submit = "feedback-notification-submit" + provide = "feedback-notification-provide" + + +def rating_select( + action_id: str = FeedbackNotificationActionIds.rating_select, + block_id: str = FeedbackNotificationBlockIds.rating_select, + initial_option: dict = None, + label: str = "Rate your experience", + **kwargs, +): + return static_select_block( + action_id=action_id, + block_id=block_id, + initial_option=initial_option, + label=label, + options=[{"text": r, "value": r} for r in FeedbackRating], + placeholder="Select a rating", + **kwargs, + ) + + +def feedback_input( + action_id: str = FeedbackNotificationActionIds.feedback_input, + block_id: str = FeedbackNotificationBlockIds.feedback_input, + initial_value: str = None, + label: str = "Give us feedback", + **kwargs, +): + return Input( + block_id=block_id, + element=PlainTextInput( + action_id=action_id, + initial_value=initial_value, + multiline=True, + placeholder="How would you describe your experience?", + ), + label=label, + **kwargs, + ) + + +def anonymous_checkbox( + action_id: str = FeedbackNotificationActionIds.anonymous_checkbox, + block_id: str = FeedbackNotificationBlockIds.anonymous_checkbox, + initial_value: str = None, + label: str = "Check the box if you wish to provide your feedback anonymously", + **kwargs, +): + options = [{"text": "Anonymize my feedback", "value": "anonymous"}] + return Input( + block_id=block_id, + element=Checkboxes(options=options, initial_value=initial_value, action_id=action_id), + label=label, + **kwargs, + ) + + +@app.action( + FeedbackNotificationActions.provide, middleware=[button_context_middleware, db_middleware] +) +async def provide_feedback_button_click(ack, body, client, respond, db_session, context): + await ack() + incident = incident_service.get( + db_session=db_session, incident_id=context["subject"].incident_id + ) + + if not incident: + message = ( + "Sorry, you cannot submit feedback about this incident. The incident does not exist." + ) + await respond(message=message, ephemeral=True) + return + blocks = [ + Context( + elements=[ + MarkdownText(text="Use this form to rate your experiance about the incident.") + ] + ), + rating_select(), + feedback_input(), + anonymous_checkbox(), + ] + + modal = Modal( + title="Incident Feedback", + blocks=blocks, + submit="Submit", + close="Cancel", + callback_id=FeedbackNotificationActions.submit, + private_metadata=context["subject"].json(), + ).build() + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + FeedbackNotificationActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_feedback_submission_event(ack, body, context, db_session, user, client, form_data): + await ack() + incident = incident_service.get( + db_session=db_session, incident_id=context["subject"].incident_id + ) + + feedback = form_data.get(FeedbackNotificationBlockIds.feedback_input) + rating = form_data.get(FeedbackNotificationBlockIds.rating_select, {}).get("value") + + feedback_in = FeedbackCreate( + rating=rating, feedback=feedback, project=incident.project, incident=incident + ) + feedback = feedback_service.create(db_session=db_session, feedback_in=feedback_in) + incident.feedback.append(feedback) + + # we only really care if this exists, if it doesn't then flag is false + if not form_data.get(FeedbackNotificationBlockIds.anonymous_checkbox): + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].incident_id, email=user.email + ) + participant.feedback.append(feedback) + db_session.add(participant) + + db_session.add(incident) + db_session.commit() + + await client.chat_PostMessage(text="Thank you for your feedback!") diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py new file mode 100644 index 000000000000..0a8657d23471 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -0,0 +1,515 @@ +from typing import List +from blockkit import ( + PlainTextInput, + StaticSelect, + PlainOption, + Input, + DatePicker, + MultiExternalSelect, +) + +from dispatch.enums import DispatchEnum +from dispatch.database.core import SessionLocal +from dispatch.project import service as project_service +from dispatch.participant.models import Participant +from dispatch.case.enums import CaseStatus +from dispatch.case.type import service as case_type_service +from dispatch.case.priority import service as case_priority_service +from dispatch.case.severity import service as case_severity_service +from dispatch.incident.enums import IncidentStatus +from dispatch.incident.type import service as incident_type_service +from dispatch.incident.priority import service as incident_priority_service +from dispatch.incident.severity import service as incident_severity_service + + +class DefaultBlockIds(DispatchEnum): + title_input = "title-input" + project_select = "project-select" + description_input = "description-input" + resolution_input = "resolution-input" + datetime_picker_input = "datetime-picker-input" + date_picker_input = "date-picker-input" + minute_picker_input = "minute-picker-input" + hour_picker_input = "hour-picker-input" + timezone_picker_input = "timezone-picker-input" + + # incidents + incident_priority_select = "incident-priority-select" + incident_status_select = "incident-status-select" + incident_severity_select = "incident-severity-select" + incident_type_select = "incident-type-select" + + # cases + case_priority_select = "case-priority-select" + case_status_select = "case-status-select" + case_severity_select = "case-severity-select" + case_type_select = "case-type-select" + + participant_select = "participant-select" + tags_multi_select = "tag-multi-select" + + +class DefaultActionIds(DispatchEnum): + title_input = "title-input" + project_select = "project-select" + description_input = "description-input" + resolution_input = "resolution-input" + datetime_picker_input = "datetime-picker-input" + date_picker_input = "date-picker-input" + minute_picker_input = "minute-picker-input" + hour_picker_input = "hour-picker-input" + timezone_picker_input = "timezone-picker-input" + + # incidents + incident_priority_select = "incident-priority-select" + incident_status_select = "incident-status-select" + incident_severity_select = "incident-severity-select" + incident_type_select = "incident-type-select" + + # cases + case_priority_select = "case-priority-select" + case_status_select = "case-status-select" + case_severity_select = "case-severity-select" + case_type_select = "case-type-select" + + participant_select = "participant-select" + tags_multi_select = "tag-multi-select" + + +class TimezoneOptions(DispatchEnum): + local = "Local Time (based on your Slack profile)" + utc = "UTC" + + +def date_picker_input( + action_id: str = DefaultActionIds.date_picker_input, + block_id: str = DefaultBlockIds.date_picker_input, + initial_date: str = None, + label: str = "Date", + **kwargs, +): + """Builds a date picker input.""" + return Input( + element=DatePicker( + action_id=action_id, initial_date=initial_date, placeholder="Select Date" + ), + block_id=block_id, + label=label, + **kwargs, + ) + + +def hour_picker_input( + action_id: str = DefaultActionIds.hour_picker_input, + block_id: str = DefaultBlockIds.hour_picker_input, + initial_option: dict = None, + label: str = "Hour", + **kwargs, +): + """Builds an hour picker input.""" + hours = [{"text": str(h).zfill(2), "value": str(h).zfill(2)} for h in range(0, 24)] + return static_select_block( + action_id=action_id, + block_id=block_id, + initial_option=initial_option, + options=hours, + label=label, + placeholder="Hour", + ) + + +def minute_picker_input( + action_id: str = DefaultActionIds.minute_picker_input, + block_id: str = DefaultBlockIds.minute_picker_input, + initial_option: dict = None, + label: str = "Minute", + **kwargs, +): + """Builds a minute picker input.""" + minutes = [{"text": str(m).zfill(2), "value": str(m).zfill(2)} for m in range(0, 60)] + return static_select_block( + action_id=action_id, + block_id=block_id, + initial_option=initial_option, + options=minutes, + label=label, + placeholder="Minute", + ) + + +def timezone_picker_input( + action_id: str = DefaultActionIds.timezone_picker_input, + block_id: str = DefaultBlockIds.timezone_picker_input, + initial_option: dict = { + "text": TimezoneOptions.local.value, + "value": TimezoneOptions.local.value, + }, + label: str = "Timezone", + **kwargs, +): + """Builds a timezone picker input.""" + return static_select_block( + action_id=action_id, + block_id=block_id, + initial_option=initial_option, + options=[{"text": tz.value, "value": tz.value} for tz in TimezoneOptions], + label=label, + placeholder="Timezone", + ) + + +def datetime_picker_block( + action_id: str = None, + block_id: str = None, + initial_option: str = None, + label: str = None, + **kwargs, +): + """Builds a datetime picker block""" + return [ + date_picker_input(), + hour_picker_input(), + minute_picker_input(), + timezone_picker_input(), + ] + + +def static_select_block( + options: List[str], + placeholder: str, + action_id: str = None, + block_id: str = None, + initial_option: dict = None, + label: str = None, + **kwargs, +): + """Builds a static select block.""" + return Input( + element=StaticSelect( + placeholder=placeholder, + options=[PlainOption(**x) for x in options], + initial_option=PlainOption(**initial_option) if initial_option else None, + action_id=action_id, + ), + block_id=block_id, + label=label, + **kwargs, + ) + + +def project_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.project_select, + block_id: str = DefaultBlockIds.project_select, + label: str = "Project", + initial_option: dict = None, + **kwargs, +): + """Creates a project select.""" + projects = [ + {"text": p.name, "value": p.id} for p in project_service.get_all(db_session=db_session) + ] + return static_select_block( + placeholder="Select Project", + options=projects, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def title_input( + label: str = "Title", + action_id: str = DefaultActionIds.title_input, + block_id: str = DefaultBlockIds.title_input, + initial_value: str = None, + **kwargs, +): + """Builds a title input.""" + return Input( + element=PlainTextInput( + placeholder="A brief explanatory title. You can change this later.", + initial_value=initial_value, + action_id=action_id, + ), + label=label, + block_id=block_id, + **kwargs, + ) + + +def description_input( + label: str = "Description", + action_id: str = DefaultActionIds.description_input, + block_id: str = DefaultBlockIds.description_input, + initial_value: str = None, + **kwargs, +): + """Builds a description input.""" + return Input( + element=PlainTextInput( + placeholder="A summary of what you know so far. It's okay if this is incomplete.", + initial_value=initial_value, + multiline=True, + action_id=action_id, + ), + block_id=block_id, + label=label, + **kwargs, + ) + + +def resolution_input( + label: str = "Resolution", + action_id: str = DefaultActionIds.resolution_input, + block_id: str = DefaultBlockIds.resolution_input, + initial_value: str = None, + **kwargs, +): + """Builds a resolution input.""" + return Input( + element=PlainTextInput( + placeholder="A description of the actions you have taken toward resolution.", + initial_value=initial_value, + multiline=True, + action_id=action_id, + ), + block_id=block_id, + label=label, + **kwargs, + ) + + +def incident_priority_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.incident_priority_select, + block_id: str = DefaultBlockIds.incident_priority_select, + label: str = "Incident Priority", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates an incident priority select.""" + priorities = [ + {"text": p.name, "value": p.id} + for p in incident_priority_service.get_all_enabled( + db_session=db_session, project_id=project_id + ) + ] + return static_select_block( + placeholder="Select Priority", + options=priorities, + initial_option=initial_option, + block_id=block_id, + action_id=action_id, + label=label, + **kwargs, + ) + + +def incident_status_select( + block_id: str = DefaultActionIds.incident_status_select, + action_id: str = DefaultBlockIds.incident_status_select, + label: str = "Incident Status", + initial_option: dict = None, + **kwargs, +): + """Creates an incident status select.""" + statuses = [{"text": s.value, "value": s.value} for s in IncidentStatus] + return static_select_block( + placeholder="Select Status", + options=statuses, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def incident_severity_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.incident_severity_select, + block_id: str = DefaultBlockIds.incident_severity_select, + label="Incident Severity", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates an incident severity select.""" + severities = [ + {"text": s.name, "value": s.id} + for s in incident_severity_service.get_all_enabled( + db_session=db_session, project_id=project_id + ) + ] + return static_select_block( + placeholder="Select Severity", + options=severities, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def incident_type_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.incident_type_select, + block_id: str = DefaultBlockIds.incident_type_select, + label="Incident Type", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates an incident type select.""" + types = [ + {"text": t.name, "value": t.id} + for t in incident_type_service.get_all_enabled(db_session=db_session, project_id=project_id) + ] + return static_select_block( + placeholder="Select Type", + options=types, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def tag_multi_select( + action_id: str = DefaultActionIds.tags_multi_select, + block_id: str = DefaultBlockIds.tags_multi_select, + label="Tags", + initial_options: str = None, + **kwargs, +): + """Creates an incident tag select.""" + return Input( + element=MultiExternalSelect( + placeholder="Select Tag(s)", action_id=action_id, initial_options=initial_options + ), + block_id=block_id, + label=label, + **kwargs, + ) + + +def case_priority_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.case_priority_select, + block_id: str = DefaultBlockIds.case_priority_select, + label="Case Priority", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates a case priority select.""" + priorities = [ + {"text": p.name, "value": p.id} + for p in case_priority_service.get_all_enabled(db_session=db_session, project_id=project_id) + ] + return static_select_block( + placeholder="Select Priority", + options=priorities, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def case_status_select( + action_id: str = DefaultActionIds.case_status_select, + block_id: str = DefaultBlockIds.case_status_select, + label: str = "Status", + initial_option: dict = None, + **kwargs, +): + """Creates a case status select.""" + statuses = [{"text": str(s), "value": str(s)} for s in CaseStatus] + return static_select_block( + placeholder="Select Status", + options=statuses, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def case_severity_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.case_severity_select, + block_id: str = DefaultBlockIds.case_severity_select, + label: str = "Case Severity", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates a case severity select.""" + severities = [ + {"text": s.name, "value": s.id} + for s in case_severity_service.get_all_enabled(db_session=db_session, project_id=project_id) + ] + return static_select_block( + placeholder="Select Severity", + options=severities, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def case_type_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.case_type_select, + block_id: str = DefaultBlockIds.case_type_select, + label: str = "Case Type", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + """Creates an case type select.""" + types = [ + {"text": t.name, "value": t.id} + for t in case_type_service.get_all_enabled(db_session=db_session, project_id=project_id) + ] + return static_select_block( + placeholder="Select Type", + options=types, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + +def participant_select( + participants: List[Participant], + action_id: str = DefaultActionIds.participant_select, + block_id: str = DefaultBlockIds.participant_select, + label: str = "Participant", + initial_option: Participant = None, + **kwargs, +): + """Creates a static select of available participants.""" + participants = [{"text": p.individual.name, "value": p.id} for p in participants] + return static_select_block( + placeholder="Select Participant", + options=participants, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) diff --git a/src/dispatch/plugins/dispatch_slack/incident/enums.py b/src/dispatch/plugins/dispatch_slack/incident/enums.py new file mode 100644 index 000000000000..b809602fb36c --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/incident/enums.py @@ -0,0 +1,150 @@ +from dispatch.enums import DispatchEnum + + +class AddTimelineEventBlockIds(DispatchEnum): + date = "add-timeline-event-date" + hour = "add-timeline-event-hour" + minute = "add-timeline-event-minute" + timezone = "add-timeline-event-timezone" + + +class AddTimelineEventActionIds(DispatchEnum): + date = "add-timeline-event-date" + hour = "add-timeline-event-hour" + minute = "add-timeline-event-minute" + timezone = "add-timeline-event-timezone" + + +class AddTimelineEventActions(DispatchEnum): + submit = "add-timeline-event-submit" + + +class TaskNotificationActions(DispatchEnum): + pass + + +class TaskNotificationActionIds(DispatchEnum): + update_status = "task-notification-update-status" + + +class TaskNotificationBlockIds(DispatchEnum): + pass + + +class LinkMonitorActions(DispatchEnum): + submit = "link-monitor-submit" + + +class LinkMonitorActionIds(DispatchEnum): + monitor = "link-monitor-monitor" + ignore = "link-monitor-ignore" + + +class LinkMonitorBlockIds(DispatchEnum): + monitor = "link-monitor-monitor" + + +class UpdateParticipantActions(DispatchEnum): + submit = "update-participant-submit" + + +class UpdateParticipantActionIds(DispatchEnum): + pass + + +class UpdateParticipantBlockIds(DispatchEnum): + reason = "update-participant-reason" + participant = "update-participant-participant" + + +class AssignRoleActions(DispatchEnum): + submit = "assign-role-submit" + + +class AssignRoleActionIds(DispatchEnum): + pass + + +class AssignRoleBlockIds(DispatchEnum): + user = "assign-role-user" + role = "assign-role-role" + + +class EngageOncallActions(DispatchEnum): + submit = "engage-oncall-submit" + + +class EngageOncallActionIds(DispatchEnum): + service = "engage-oncall-service" + page = "engage-oncall-page" + + +class EngageOncallBlockIds(DispatchEnum): + service = "engage-oncall-service" + page = "engage-oncall-page" + + +class ReportTacticalActions(DispatchEnum): + submit = "report-tactical-submit" + + +class ReportTacticalActionIds(DispatchEnum): + pass + + +class ReportTacticalBlockIds(DispatchEnum): + needs = "report-tactical-needs" + actions = "report-tactical-actions" + conditions = "report-tactical-conditions" + + +class ReportExecutiveActions(DispatchEnum): + submit = "report-executive-submit" + + +class ReportExecutiveActionIds(DispatchEnum): + pass + + +class ReportExecutiveBlockIds(DispatchEnum): + current_status = "report-executive-current-status" + overview = "report-executive-overview" + next_steps = "report-executive-next-steps" + + +class IncidentUpdateActions(DispatchEnum): + submit = "incident-update-submit" + project_select = "incident-update-project-select" + + +class IncidentUpdateActionIds(DispatchEnum): + tags_multi_select = "incident-update-tags-multi-select" + + +class IncidentUpdateBlockIds(DispatchEnum): + tags_multi_select = "incident-update-tags-multi-select" + + +class IncidentReportActions(DispatchEnum): + submit = "incident-report-submit" + project_select = "incident-report-project-select" + + +class IncidentReportActionIds(DispatchEnum): + tags_multi_select = "incident-report-tags-multi-select" + + +class IncidentReportBlockIds(DispatchEnum): + tags_multi_select = "incident-report-tags-multi-select" + + +class UpdateNotificationGroupActions(DispatchEnum): + submit = "update-notification-group-submit" + + +class UpdateNotificationGroupActionIds(DispatchEnum): + members = "update-notification-group-members" + + +class UpdateNotificationGroupBlockIds(DispatchEnum): + members = "update-notification-group-members" diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py new file mode 100644 index 000000000000..49fba53b8fbd --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -0,0 +1,2062 @@ +import logging +from datetime import datetime +from typing import List, Any + +import pytz +from blockkit import ( + Actions, + Button, + Checkboxes, + Context, + Divider, + Image, + Input, + MarkdownText, + Message, + Modal, + PlainOption, + PlainTextInput, + Section, + UsersSelect, +) +from slack_bolt.async_app import AsyncAck, AsyncBoltContext, AsyncRespond +from slack_sdk.web.async_client import AsyncWebClient + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from dispatch.auth.models import DispatchUser +from dispatch.config import DISPATCH_UI_URL +from dispatch.database.core import engine, resolve_attr, sessionmaker +from dispatch.database.service import search_filter_sort_paginate +from dispatch.document import service as document_service +from dispatch.enums import Visibility +from dispatch.event import service as event_service +from dispatch.incident import flows as incident_flows +from dispatch.incident import service as incident_service +from dispatch.incident.enums import IncidentStatus +from dispatch.incident.models import IncidentCreate, IncidentRead, IncidentUpdate +from dispatch.individual import service as individual_service +from dispatch.individual.models import IndividualContactRead +from dispatch.messaging.strings import INCIDENT_RESOURCES_MESSAGE, MessageType +from dispatch.monitor import service as monitor_service +from dispatch.nlp import build_phrase_matcher, build_term_vocab, extract_terms_from_text +from dispatch.participant import service as participant_service +from dispatch.participant.models import ParticipantUpdate +from dispatch.participant_role import service as participant_role_service +from dispatch.participant_role.enums import ParticipantRoleType +from dispatch.plugin import service as plugin_service +from dispatch.plugins.dispatch_slack import service as dispatch_slack_service +from dispatch.plugins.dispatch_slack.bolt import app +from dispatch.plugins.dispatch_slack.decorators import message_dispatcher +from dispatch.plugins.dispatch_slack.fields import ( + DefaultActionIds, + DefaultBlockIds, + TimezoneOptions, + datetime_picker_block, + description_input, + incident_priority_select, + incident_severity_select, + incident_status_select, + incident_type_select, + participant_select, + project_select, + resolution_input, + static_select_block, + tag_multi_select, + title_input, +) +from dispatch.plugins.dispatch_slack.incident.enums import ( + AddTimelineEventActions, + AssignRoleActions, + AssignRoleBlockIds, + EngageOncallActionIds, + EngageOncallActions, + EngageOncallBlockIds, + IncidentReportActions, + IncidentUpdateActions, + IncidentUpdateBlockIds, + LinkMonitorActionIds, + LinkMonitorBlockIds, + ReportExecutiveActions, + ReportExecutiveBlockIds, + ReportTacticalActions, + ReportTacticalBlockIds, + TaskNotificationActionIds, + UpdateNotificationGroupActionIds, + UpdateNotificationGroupActions, + UpdateNotificationGroupBlockIds, + UpdateParticipantActions, + UpdateParticipantBlockIds, +) +from dispatch.plugins.dispatch_slack.messaging import create_message_blocks +from dispatch.plugins.dispatch_slack.middleware import ( + action_context_middleware, + command_context_middleware, + configuration_middleware, + db_middleware, + message_context_middleware, + modal_submit_middleware, + restricted_command_middleware, + user_middleware, +) +from dispatch.plugins.dispatch_slack.models import SubjectMetadata +from dispatch.plugins.dispatch_slack.service import ( + chunks, + get_user_email_async, + get_user_profile_by_email_async, +) +from dispatch.project import service as project_service +from dispatch.report import flows as report_flows +from dispatch.report import service as report_service +from dispatch.report.enums import ReportTypes +from dispatch.report.models import ExecutiveReportCreate, TacticalReportCreate +from dispatch.service import service as service_service +from dispatch.tag import service as tag_service +from dispatch.tag.models import Tag +from dispatch.task import service as task_service +from dispatch.task.enums import TaskStatus +from dispatch.task.models import Task + +log = logging.getLogger(__file__) + + +class TaskMetadata(SubjectMetadata): + resource_id: str + + +class MonitorMetadata(SubjectMetadata): + weblink: str + + +def configure(config): + """Maps commands/events to their functions.""" + middleware = [ + command_context_middleware, + db_middleware, + user_middleware, + configuration_middleware, + ] + + # don't need an incident context + app.command(config.slack_command_list_incidents, middleware=[db_middleware])( + handle_list_incidents_command + ) + app.command(config.slack_command_report_incident, middleware=[db_middleware])( + handle_report_incident_command + ) + + # non-sensitive-commands + app.command(config.slack_command_list_tasks, middleware=middleware)(handle_list_tasks_command) + app.command(config.slack_command_list_my_tasks, middleware=middleware)( + handle_list_tasks_command + ) + app.command(config.slack_command_list_participants, middleware=middleware)( + handle_list_participants_command + ) + app.command(config.slack_command_update_participant, middleware=middleware)( + handle_update_participant_command + ) + app.command(config.slack_command_list_resources, middleware=middleware)( + handle_list_resources_command + ) + app.command(config.slack_command_engage_oncall, middleware=middleware)( + handle_engage_oncall_command + ) + + # sensitive commands + middleware.append(restricted_command_middleware) + app.command(config.slack_command_assign_role, middleware=middleware)(handle_assign_role_command) + app.command(config.slack_command_update_incident, middleware=middleware)( + handle_update_incident_command + ) + app.command(config.slack_command_update_notifications_group, middleware=middleware)( + handle_update_notifications_group_command + ) + app.command(config.slack_command_report_tactical, middleware=middleware)( + handle_report_tactical_command + ) + app.command(config.slack_command_report_executive, middleware=middleware)( + handle_report_executive_command + ) + app.command(config.slack_command_add_timeline_event, middleware=middleware)( + handle_add_timeline_event_command + ) + + # required to allow the user to change the reaction string + app.event(config.timeline_event_reaction, middleware=[db_middleware])( + handle_timeline_added_event + ) + + +def refetch_db_session(organization_slug: str) -> Session: + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() + return db_session + + +@app.options( + DefaultActionIds.tags_multi_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_tag_search_action( + ack: AsyncAck, payload: dict, context: AsyncBoltContext, db_session: Session +) -> None: + """Handles tag lookup actions.""" + query_str = payload["value"] + + filter_spec = { + "and": [ + {"model": "Project", "op": "==", "field": "id", "value": context["subject"].project_id} + ] + } + + if "/" in query_str: + tag_type, query_str = query_str.split("/") + filter_spec["and"].append( + {"model": "TagType", "op": "==", "field": "name", "value": tag_type} + ) + + tags = search_filter_sort_paginate( + db_session=db_session, model="Tag", query_str=query_str, filter_spec=filter_spec + ) + + options = [] + for t in tags["items"]: + options.append( + { + "text": {"type": "plain_text", "text": f"{t.tag_type.name}/{t.name}"}, + "value": str(t.id), # NOTE: slack doesn't accept int's as values (fails silently) + } + ) + + await ack(options=options) + + +@app.action( + IncidentUpdateActions.project_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_update_incident_project_select_action( + ack: AsyncAck, + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + db_session: Session, +) -> None: + await ack() + values = body["view"]["state"]["values"] + + project_id = values[DefaultBlockIds.project_select][IncidentUpdateActions.project_select][ + "selected_option" + ]["value"] + + project = project_service.get( + db_session=db_session, + project_id=project_id, + ) + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + blocks = [ + Context(elements=[MarkdownText(text="Use this form to update the incident's details.")]), + title_input(initial_value=incident.title), + description_input(initial_value=incident.description), + resolution_input(initial_value=incident.resolution), + incident_status_select(initial_option={"text": incident.status, "value": incident.status}), + project_select( + db_session=db_session, + initial_option={"text": project.name, "value": project.id}, + action_id=IncidentUpdateActions.project_select, + dispatch_action=True, + ), + incident_type_select( + db_session=db_session, + initial_option={ + "text": incident.incident_type.name, + "value": incident.incident_type.id, + }, + project_id=project.id, + ), + incident_severity_select( + db_session=db_session, + initial_option={ + "text": incident.incident_severity.name, + "value": incident.incident_severity.id, + }, + project_id=project.id, + ), + incident_priority_select( + db_session=db_session, + initial_option={ + "text": incident.incident_priority.name, + "value": incident.incident_priority.id, + }, + project_id=project.id, + ), + tag_multi_select( + optional=True, + initial_options=[t.name for t in incident.tags], + ), + ] + + modal = Modal( + title="Update Incident", + blocks=blocks, + submit="Update", + close="Cancel", + callback_id=IncidentUpdateActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + + +# COMMANDS +async def handle_list_incidents_command( + ack: AsyncAck, + payload: dict, + respond: AsyncRespond, + db_session: Session, + context: AsyncBoltContext, +) -> None: + """Handles the list incidents command.""" + await ack() + projects = [] + + if context["subject"].type == "incident": + # command was run in an incident conversation + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + projects.append(incident.project) + else: + # command was run in a non-incident conversation + args = payload["command"].split(" ") + + if len(args) == 2: + project = project_service.get_by_name(db_session=db_session, name=args[1]) + + if project: + projects.append(project) + else: + await respond( + text=f"Project name '{args[1]}' in organization '{args[0]}' not found. Check your spelling.", + response_type="ephemeral", + ) + return + else: + projects = project_service.get_all(db_session=db_session) + + incidents = [] + for project in projects: + # We fetch active incidents + incidents.extend( + incident_service.get_all_by_status( + db_session=db_session, project_id=project.id, status=IncidentStatus.active + ) + ) + # We fetch stable incidents + incidents.extend( + incident_service.get_all_by_status( + db_session=db_session, + project_id=project.id, + status=IncidentStatus.stable, + ) + ) + # We fetch closed incidents in the last 24 hours + incidents.extend( + incident_service.get_all_last_x_hours_by_status( + db_session=db_session, + project_id=project.id, + status=IncidentStatus.closed, + hours=24, + ) + ) + + blocks = [Section(text="*Incident List*")] + + if incidents: + for incident in incidents: + if incident.visibility == Visibility.open: + incident_weblink = f"{DISPATCH_UI_URL}/{incident.project.organization.name}/incidents/{incident.name}?project={incident.project.name}" + + blocks.append(Section(text=f"*<{incident_weblink}|{incident.name}>*")) + blocks.append( + Section( + fields=[ + f"*Title*\n {incident.title}", + f"*Commander*\n<{incident.commander.individual.weblink}|{incident.commander.individual.name}>", + f"*Project*\n{incident.project.name}", + f"*Status*\n{incident.status}", + f"*Type*\n {incident.incident_type.name}", + f"*Severity*\n {incident.incident_severity.name}", + f"*Priority*\n {incident.incident_priority.name}", + ] + ) + ) + blocks.append(Divider()) + + for c in chunks(blocks, 50): + blocks = Message(blocks=c).build()["blocks"] + await respond(text="Incident List", blocks=blocks, response_type="ephemeral") + + +async def handle_list_participants_command( + ack: AsyncAck, + respond: AsyncRespond, + client: AsyncWebClient, + db_session: Session, + context: AsyncBoltContext, +) -> None: + """Handles list participants command.""" + await ack() + blocks = [Section(text="*Incident Participants*")] + participants = participant_service.get_all_by_incident_id( + db_session=db_session, incident_id=context["subject"].id + ).all() + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + contact_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="contact" + ) + if not contact_plugin: + await respond( + text="Contact plugin is not enabled. Unable to list participants.", + response_type="ephemeral", + ) + return + + for participant in participants: + if participant.active_roles: + participant_email = participant.individual.email + participant_info = contact_plugin.instance.get(participant_email, db_session=db_session) + participant_name = participant_info.get("fullname", participant.individual.email) + participant_team = participant_info.get("team", "Unknown") + participant_department = participant_info.get("department", "Unknown") + participant_location = participant_info.get("location", "Unknown") + participant_weblink = participant_info.get("weblink") + + participant_active_roles = participant_role_service.get_all_active_roles( + db_session=db_session, participant_id=participant.id + ) + participant_roles = [] + for role in participant_active_roles: + participant_roles.append(role.role) + + accessory = None + # don't load avatars for large incidents + if len(participants) < 20: + participant_avatar_url = await dispatch_slack_service.get_user_avatar_url_async( + client, participant_email + ) + accessory = Image(image_url=participant_avatar_url, alt_text=participant_name) + + blocks.extend( + [ + Section( + fields=[ + f"*Name* \n<{participant_weblink}|{participant_name} ({participant_email})>", + f"*Team*\n {participant_team}, {participant_department}", + f"*Location* \n{participant_location}", + f"*Incident Role(s)* \n{(', ').join(participant_roles)}", + ], + accessory=accessory, + ), + Divider(), + ] + ) + + blocks = Message(blocks=blocks).build()["blocks"] + await respond(text="Incident Participant List", blocks=blocks, response_type="ephemeral") + + +def filter_tasks_by_assignee_and_creator( + tasks: List[Task], by_assignee: str, by_creator: str +) -> list[Task]: + """Filters a list of tasks looking for a given creator or assignee.""" + filtered_tasks = [] + for t in tasks: + if by_creator: + creator_email = t.creator.individual.email + if creator_email == by_creator: + filtered_tasks.append(t) + # lets avoid duplication if creator is also assignee + continue + + if by_assignee: + assignee_emails = [a.individual.email for a in t.assignees] + if by_assignee in assignee_emails: + filtered_tasks.append(t) + + return filtered_tasks + + +async def handle_list_tasks_command( + ack: AsyncAck, + user: DispatchUser, + body: dict, + respond: AsyncRespond, + context: AsyncBoltContext, + db_session: Session, +) -> None: + """Handles the list tasks command.""" + await ack() + blocks = [] + + caller_only = False + if body["command"] == context["config"].slack_command_list_my_tasks: + caller_only = True + + for status in TaskStatus: + blocks.append(Section(text=f"*{status} Incident Tasks*")) + button_text = "Resolve" if status == TaskStatus.open else "Re-open" + + tasks = task_service.get_all_by_incident_id_and_status( + db_session=db_session, incident_id=context["subject"].id, status=status + ) + + if caller_only: + tasks = filter_tasks_by_assignee_and_creator(tasks, user.email, user.email) + + if not tasks: + blocks.append(Section(text="No tasks.")) + + for idx, task in enumerate(tasks): + assignees = [f"<{a.individual.weblink}|{a.individual.name}>" for a in task.assignees] + + button_metadata = TaskMetadata( + type="incident", + organization_slug=task.project.organization.slug, + id=task.incident.id, + project_id=task.project.id, + resource_id=task.resource_id, + channel_id=context["channel_id"], + ).json() + + blocks.append( + Section( + fields=[ + f"*Description:* \n <{task.weblink}|{task.description}>", + f"*Creator:* \n <{task.creator.individual.weblink}|{task.creator.individual.name}>", + f"*Assignees:* \n {', '.join(assignees)}", + ], + accessory=Button( + text=button_text, + value=button_metadata, + action_id=TaskNotificationActionIds.update_status, + ), + ) + ) + blocks.append(Divider()) + + message = Message(blocks=blocks).build()["blocks"] + await respond(text="Incident Task List", blocks=message, response_type="ephermeral") + + +async def handle_list_resources_command( + ack: AsyncAck, respond: AsyncRespond, db_session: Session, context: AsyncBoltContext +) -> None: + """Handles the list resources command.""" + await ack() + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + incident_description = ( + incident.description + if len(incident.description) <= 500 + else f"{incident.description[:500]}..." + ) + + # we send the ephemeral message + message_kwargs = { + "title": incident.title, + "description": incident_description, + "commander_fullname": incident.commander.individual.name, + "commander_team": incident.commander.team, + "commander_weblink": incident.commander.individual.weblink, + "reporter_fullname": incident.reporter.individual.name, + "reporter_team": incident.reporter.team, + "reporter_weblink": incident.reporter.individual.weblink, + "document_weblink": resolve_attr(incident, "incident_document.weblink"), + "storage_weblink": resolve_attr(incident, "storage.weblink"), + "conference_weblink": resolve_attr(incident, "conference.weblink"), + "conference_challenge": resolve_attr(incident, "conference.conference_challenge"), + } + + faq_doc = document_service.get_incident_faq_document( + db_session=db_session, project_id=incident.project_id + ) + if faq_doc: + message_kwargs.update({"faq_weblink": faq_doc.weblink}) + + conversation_reference = document_service.get_conversation_reference_document( + db_session=db_session, project_id=incident.project_id + ) + if conversation_reference: + message_kwargs.update( + {"conversation_commands_reference_document_weblink": conversation_reference.weblink} + ) + + blocks = create_message_blocks( + INCIDENT_RESOURCES_MESSAGE, MessageType.incident_resources_message, **message_kwargs + ) + blocks = Message(blocks=blocks).build()["blocks"] + await respond(text="Incident Resources", blocks=blocks, response_type="ephemeral") + + +# EVENTS + + +async def handle_timeline_added_event( + client: Any, context: AsyncBoltContext, db_session: Session +) -> None: + """Handles an event where a reaction is added to a message.""" + conversation_id = context["channel_id"] + message_ts = context["ts"] + message_ts_utc = datetime.datetime.utcfromtimestamp(float(message_ts)) + + # we fetch the message information + response = dispatch_slack_service.list_conversation_messages( + client, conversation_id, latest=message_ts, limit=1, inclusive=1 + ) + message_text = response["messages"][0]["text"] + message_sender_id = response["messages"][0]["user"] + + # TODO: (wshel) handle case reactions + if context["subject"].type == "incident": + # we fetch the incident + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + # we fetch the individual who sent the message + message_sender_email = await get_user_email_async(client=client, user_id=message_sender_id) + individual = individual_service.get_by_email_and_project( + db_session=db_session, email=message_sender_email, project_id=incident.project.id + ) + + # we log the event + event_service.log_incident_event( + db_session=db_session, + source="Slack Plugin - Conversation Management", + description=f'"{message_text}," said {individual.name}', + incident_id=context["subject"].id, + individual_id=individual.id, + started_at=message_ts_utc, + ) + + +@message_dispatcher.add( + exclude={"subtype": ["channel_join", "channel_leave"]} +) # we ignore channel join and leave messages +async def handle_participant_role_activity( + ack: AsyncAck, db_session: Session, context: AsyncBoltContext, user: DispatchUser +) -> None: + """ + Increments the participant role's activity counter and assesses the need of changing + a participant's role based on its activity and changes it if needed. + """ + await ack() + + # TODO: add when case support when participants are added. + if context["subject"].type == "incident": + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].id, email=user.email + ) + + if participant: + for participant_role in participant.active_roles: + participant_role.activity += 1 + + # re-assign role once threshold is reached + if participant_role.role == ParticipantRoleType.observer: + if participant_role.activity >= 10: # ten messages sent to the incident channel + # we change the participant's role to the participant one + participant_role_service.renounce_role( + db_session=db_session, participant_role=participant_role + ) + participant_role_service.add_role( + db_session=db_session, + participant_id=participant.id, + participant_role=ParticipantRoleType.participant, + ) + + # we log the event + event_service.log_incident_event( + db_session=db_session, + source="Slack Plugin - Conversation Management", + description=( + f"{participant.individual.name}'s role changed from {participant_role.role} to " + f"{ParticipantRoleType.participant} due to activity in the incident channel" + ), + incident_id=context["subject"].id, + ) + + db_session.commit() + + +@message_dispatcher.add( + exclude={"subtype": ["channel_join", "group_join"]} +) # we ignore user channel and group join messages +async def handle_after_hours_message( + ack: AsyncAck, + context: AsyncBoltContext, + client: AsyncWebClient, + payload: dict, + user: DispatchUser, + db_session: Session, +) -> None: + """Notifies the user that this incident is currently in after hours mode.""" + await ack() + + if context["subject"].type == "incident": + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + owner_email = incident.commander.individual.email + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].id, email=user.email + ) + # get their timezone from slack + owner_tz = ( + await dispatch_slack_service.get_user_info_by_email_async(client, email=owner_email) + )["tz"] + message = f"Responses may be delayed. The current incident priority is *{incident.incident_priority.name}* and your message was sent outside of the Incident Commander's working hours (Weekdays, 9am-5pm, {owner_tz} timezone)." + else: + # TODO: add case support + return + + now = datetime.now(pytz.timezone(owner_tz)) + is_business_hours = now.weekday() not in [5, 6] and 9 <= now.hour < 17 + + if not is_business_hours: + if not participant.after_hours_notification: + participant.after_hours_notification = True + db_session.add(participant) + db_session.commit() + await client.chat_postEphemeral( + text=message, + channel=payload["channel"], + user=payload["user"], + ) + + +@message_dispatcher.add() +async def handle_thread_creation( + client: AsyncWebClient, payload: dict, context: AsyncBoltContext +) -> None: + """Sends the user an ephemeral message if they use threads.""" + if not context["config"].ban_threads: + return + + if context["subject"].type == "incident": + if payload.get("thread_ts"): + message = "Please refrain from using threads in incident channels. Threads make it harder for incident participants to maintain context." + await client.chat_postEphemeral( + text=message, + channel=payload["channel"], + thread_ts=payload["thread_ts"], + user=payload["user"], + ) + + +@message_dispatcher.add() +async def handle_message_tagging( + db_session: Session, payload: dict, context: AsyncBoltContext +) -> None: + """Looks for incident tags in incident messages.""" + + # TODO: (wshel) handle case tagging + if context["subject"].type == "incident": + text = payload["text"] + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + tags = tag_service.get_all(db_session=db_session, project_id=incident.project.id).all() + tag_strings = [t.name.lower() for t in tags if t.discoverable] + phrases = build_term_vocab(tag_strings) + matcher = build_phrase_matcher("dispatch-tag", phrases) + extracted_tags = list(set(extract_terms_from_text(text, matcher))) + + matched_tags = ( + db_session.query(Tag) + .filter(func.upper(Tag.name).in_([func.upper(t) for t in extracted_tags])) + .all() + ) + + incident.tags.extend(matched_tags) + db_session.commit() + + +@message_dispatcher.add() +async def handle_message_monitor( + ack: AsyncAck, + payload: dict, + context: AsyncBoltContext, + client: AsyncWebClient, + db_session: Session, + respond: AsyncRespond, +) -> None: + """Looks for strings that are available for monitoring (e.g. links).""" + await ack() + + if context["subject"].type == "incident": + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + project_id = incident.project.id + else: + # TODO: handle cases + await respond( + text="Command is not currently available for cases.", response_type="ephemeral" + ) + return + + plugins = plugin_service.get_active_instances( + db_session=db_session, project_id=project_id, plugin_type="monitor" + ) + + for p in plugins: + for matcher in p.instance.get_matchers(): + for match in matcher.finditer(payload["text"]): + match_data = match.groupdict() + monitor = monitor_service.get_by_weblink( + db_session=db_session, weblink=match_data["weblink"] + ) + + # silence ignored matches + if monitor: + continue + + current_status = p.instance.get_match_status(match_data) + if current_status: + status_text = "" + for k, v in current_status.items(): + status_text += f"*{k.title()}*:\n{v.title()}\n" + + button_metadata = MonitorMetadata( + type="incident", + organization_slug=incident.project.organization.slug, + id=incident.id, + project_id=incident.project.id, + channel_id=context["channel_id"], + weblink=match_data["weblink"], + ) + + blocks = [ + Section( + text=f"Hi! Dispatch is able to monitor the status of the following resource: \n {match_data['weblink']} \n\n Would you like to be notified about changes in its status in the incident channel?" + ), + Section(text=status_text), + Actions( + block_id=LinkMonitorBlockIds.monitor, + elements=[ + Button( + text="Monitor", + action_id=LinkMonitorActionIds.monitor, + style="primary", + value=button_metadata, + ), + Button( + text="Ignore", + action_id=LinkMonitorActionIds.ignore, + style="primary", + value=button_metadata, + ), + ], + ), + ] + blocks = Message(blocks=blocks).build()["blocks"] + await client.chat_postEphemeral( + text="Link Monitor", + channel=payload["channel"], + thread_ts=payload["thread_ts"], + blocks=blocks, + user=payload["user"], + ) + + +@app.event( + "member_joined_channel", + middleware=[ + message_context_middleware, + user_middleware, + db_middleware, + configuration_middleware, + ], +) +async def handle_member_joined_channel( + ack: AsyncAck, + user: DispatchUser, + body: dict, + client: AsyncWebClient, + db_session: Session, + context: AsyncBoltContext, +) -> None: + """Handles the member_joined_channel Slack event.""" + await ack() + + participant = incident_flows.incident_add_or_reactivate_participant_flow( + user_email=user.email, incident_id=context["subject"].id, db_session=db_session + ) + + # If the user was invited, the message will include an inviter property containing the user ID of the inviting user. + # The property will be absent when a user manually joins a channel, or a user is added by default (e.g. #general channel). + inviter = body.get("event", {}).get("inviter", None) + inviter_is_user = ( + dispatch_slack_service.is_user(context["config"], inviter) if inviter else None + ) + + if inviter and inviter_is_user: + # Participant is added into the incident channel using an @ message or /invite command. + inviter_email = await get_user_email_async(client=client, user_id=inviter) + added_by_participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].id, email=inviter_email + ) + participant.added_by = added_by_participant + else: + # User joins via the `join` button on Web Application or Slack. + # We default to the incident commander when we don't know who added the user or the user is the Dispatch bot. + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + participant.added_by = incident.commander + + # Message text when someone @'s a user is not available in body, use generic added by reason + participant.added_reason = f"Participant added by {participant.added_by}" + + db_session.add(participant) + db_session.commit() + + +@app.event( + "member_left_channel", middleware=[message_context_middleware, user_middleware, db_middleware] +) +async def handle_member_left_channel( + ack: AsyncAck, context: AsyncBoltContext, db_session: Session, user: DispatchUser +) -> None: + await ack() + + incident_flows.incident_remove_participant_flow( + user.email, context["subject"].id, db_session=db_session + ) + + +# MODALS +async def handle_add_timeline_event_command( + ack: AsyncAck, body: dict, client: AsyncWebClient, context: AsyncBoltContext +) -> None: + """Handles the add timeline event command.""" + await ack() + blocks = [ + Context( + elements=[ + MarkdownText(text="Use this form to add an event to the incident's timeline.") + ] + ), + description_input(), + ] + + blocks.extend(datetime_picker_block()) + + modal = Modal( + title="Add Timeline Event", + blocks=blocks, + submit="Add", + close="Close", + callback_id=AddTimelineEventActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open( + trigger_id=body["trigger_id"], + view=modal, + ) + + +async def ack_add_timeline_submission_event(ack: AsyncAck) -> None: + """Handles the add timeline submission event acknowledgement.""" + modal = Modal( + title="Add Timeline Event", close="Close", blocks=[Section(text="Adding timeline event...")] + ).build() + await ack(response_action="update", view=modal) + + +async def handle_add_timeline_submission_event( + body: dict, + user: DispatchUser, + client: AsyncWebClient, + context: AsyncBoltContext, + form_data: dict, +): + """Handles the add timeline submission event.""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + event_date = form_data.get(DefaultBlockIds.date_picker_input) + event_hour = form_data.get(DefaultBlockIds.hour_picker_input)["value"] + event_minute = form_data.get(DefaultBlockIds.minute_picker_input)["value"] + event_timezone_selection = form_data.get(DefaultBlockIds.timezone_picker_input)["value"] + event_description = form_data.get(DefaultBlockIds.description_input) + + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].id, email=user.email + ) + + event_timezone = event_timezone_selection + if event_timezone_selection == TimezoneOptions.local: + participant_profile = await get_user_profile_by_email_async(client, user.email) + if participant_profile.get("tz"): + event_timezone = participant_profile.get("tz") + + event_dt = datetime.fromisoformat(f"{event_date}T{event_hour}:{event_minute}") + event_dt_utc = pytz.timezone(event_timezone).localize(event_dt).astimezone(pytz.utc) + + event_service.log_incident_event( + db_session=db_session, + source="Slack Plugin - Conversation Management", + started_at=event_dt_utc, + description=f'"{event_description}," said {participant.individual.name}', + incident_id=context["subject"].id, + individual_id=participant.individual.id, + ) + + modal = Modal( + title="Add Timeline Event", + close="Close", + blocks=[Section(text="Adding timeline event... Success!")], + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + AddTimelineEventActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_add_timeline_submission_event, lazy=[handle_add_timeline_submission_event]) + + +async def handle_update_participant_command( + ack: AsyncAck, + respond: AsyncRespond, + body: dict, + context: AsyncBoltContext, + db_session: Session, + client: AsyncWebClient, +) -> None: + """Handles the update participant command.""" + await ack() + + if context["subject"].type == "case": + await respond( + text="Command is not currently available for cases.", response_type="ephemeral" + ) + return + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + blocks = [ + Context( + elements=[ + MarkdownText( + text="Use this form to update the reason why the participant was added to the incident." + ) + ] + ), + participant_select( + block_id=UpdateParticipantBlockIds.participant, + participants=incident.participants, + ), + Input( + element=PlainTextInput(placeholder="Reason for addition"), + label="Reason added", + block_id=UpdateParticipantBlockIds.reason, + ), + ] + + modal = Modal( + title="Update Participant", + blocks=blocks, + submit="Update", + close="Cancel", + callback_id=UpdateParticipantActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_update_participant_submission_event(ack): + """Handles the update participant submission event.""" + modal = Modal( + title="Update Participant", close="Close", blocks=[Section(text="Updating participant...")] + ).build() + await ack(response_action="update", view=modal) + + +async def handle_update_participant_submission_event( + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + db_session: Session, + form_data: dict, +) -> None: + """Handles the update participant submission event.""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + added_reason = form_data.get(UpdateParticipantBlockIds.reason) + participant_id = int(form_data.get(UpdateParticipantBlockIds.participant)["value"]) + selected_participant = participant_service.get( + db_session=db_session, participant_id=participant_id + ) + participant_service.update( + db_session=db_session, + participant=selected_participant, + participant_in=ParticipantUpdate(added_reason=added_reason), + ) + modal = Modal( + title="Update Participant", + close="Close", + blocks=[Section(text="Updating participant...Success!")], + ).build() + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + UpdateParticipantActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_update_participant_submission_event, lazy=[handle_update_participant_submission_event]) + + +async def handle_update_notifications_group_command( + ack: AsyncAck, + respond: AsyncRespond, + body: dict, + context: AsyncBoltContext, + client: AsyncWebClient, + db_session: Session, +) -> None: + """Handles the update notification group command.""" + await ack() + + # TODO handle cases + if context["subject"].type == "case": + await respond( + text="Command is not currently available for cases.", response_type="ephemeral" + ) + return + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + group_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" + ) + if not group_plugin: + await respond( + text="Group plugin is not enabled. Unable to update notifications group.", + response_type="ephemeral", + ) + return + + if not incident.notifications_group: + await respond( + text="No notification group available for this incident.", response_type="ephemeral" + ) + return + + members = group_plugin.instance.list(incident.notifications_group.email) + + blocks = [ + Context( + elements=[ + MarkdownText( + text="Use this form to update the membership of the notifications group." + ) + ] + ), + Input( + label="Members", + element=PlainTextInput( + initial_value=", ".join(members), + multiline=True, + action_id=UpdateNotificationGroupActionIds.members, + ), + block_id=UpdateNotificationGroupBlockIds.members, + ), + Context(elements=[MarkdownText(text="Separate email addresses with commas")]), + ] + + modal = Modal( + title="Update Group Members", # 24 Char Limit + blocks=blocks, + close="Cancel", + submit="Update", + callback_id=UpdateNotificationGroupActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_update_notifications_group_submission_event(ack): + """Handles the update notifications group submission acknowledgement.""" + modal = Modal( + title="Update Group Members", + close="Close", + blocks=[Section(text="Updating notifications group...")], + ).build() + await ack(response_action="update", view=modal) + + +async def handle_update_notifications_group_submission_event( + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the update notifications group submission event.""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + + current_members = ( + body["view"]["blocks"][1]["element"]["initial_value"].replace(" ", "").split(",") + ) + updated_members = ( + form_data.get(UpdateNotificationGroupBlockIds.members).replace(" ", "").split(",") + ) + members_added = list(set(updated_members) - set(current_members)) + members_removed = list(set(current_members) - set(updated_members)) + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + group_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" + ) + + group_plugin.instance.add(incident.notifications_group.email, members_added) + group_plugin.instance.remove(incident.notifications_group.email, members_removed) + + modal = Modal( + title="Update Group Members", + blocks=[Section(text="Updating notification group members... Success!")], + close="Close", + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + UpdateNotificationGroupActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)( + ack=ack_update_notifications_group_submission_event, + lazy=[handle_update_notifications_group_submission_event], +) + + +async def handle_assign_role_command( + ack: AsyncAck, context: AsyncBoltContext, body: dict, client: AsyncWebClient +) -> None: + """Handles the assign role command.""" + await ack() + + roles = [ + {"text": r.value, "value": r.value} + for r in ParticipantRoleType + if r != ParticipantRoleType.participant + ] + + blocks = [ + Context( + elements=[ + MarkdownText( + text="Assign a role to a participant. Note: The participant will be invited to the incident channel if they are not yet a member." + ) + ] + ), + Input( + block_id=AssignRoleBlockIds.user, + label="Participant", + element=UsersSelect(placeholder="Participant"), + ), + static_select_block( + placeholder="Select Role", label="Role", options=roles, block_id=AssignRoleBlockIds.role + ), + ] + + modal = Modal( + title="Assign Role", + submit="Assign", + close="Cancel", + blocks=blocks, + callback_id=AssignRoleActions.submit, + private_metadata=context["subject"].json(), + ).build() + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_assign_role_submission_event(ack: AsyncAck): + """Handles the assign role submission acknowledgement.""" + modal = Modal( + title="Assign Role", close="Close", blocks=[Section(text="Assigning role...")] + ).build() + await ack(response_action="update", view=modal) + + +async def handle_assign_role_submission_event( + body: dict, + user: DispatchUser, + client: AsyncWebClient, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the assign role submission.""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + + assignee_user_id = form_data[AssignRoleBlockIds.user]["value"] + assignee_role = form_data[AssignRoleBlockIds.role]["value"] + assignee_email = await get_user_email_async(client=client, user_id=assignee_user_id) + + # we assign the role + incident_flows.incident_assign_role_flow( + incident_id=context["subject"].id, + assigner_email=user.email, + assignee_email=assignee_email, + assignee_role=assignee_role, + db_session=db_session, + ) + + if ( + assignee_role == ParticipantRoleType.reporter + or assignee_role == ParticipantRoleType.incident_commander # noqa + ): + # we update the external ticket + incident_flows.update_external_incident_ticket( + incident_id=context["subject"].id, db_session=context["subject"].organization_slug + ) + + modal = Modal( + title="Assign Role", blocks=[Section(text="Assigning role... Success!")], close="Close" + ).build() + await client.views_update(view_id=body["view"]["id"], view=modal) + + +app.view( + AssignRoleActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_assign_role_submission_event, lazy=[handle_assign_role_submission_event]) + + +async def handle_engage_oncall_command( + ack: AsyncAck, + respond: AsyncRespond, + context: AsyncBoltContext, + body: dict, + client: AsyncWebClient, + db_session: Session, +) -> None: + """Handles the engage oncall command.""" + await ack() + + # TODO: handle cases + if context["subject"].type == "case": + await respond( + text="Command is not currently available for cases.", response_type="ephemeral" + ) + return + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + oncall_services = service_service.get_all_by_project_id_and_status( + db_session=db_session, project_id=incident.project.id, is_active=True + ) + + if not oncall_services.count(): + await respond( + blocks=Message( + blocks=[ + Section( + text="No oncall services have been defined. You can define them in the Dispatch UI at /services." + ) + ] + ).build()["blocks"], + response_type="ephemeral", + ) + return + + services = [{"text": s.name, "value": s.external_id} for s in oncall_services] + + blocks = [ + static_select_block( + label="Service", + action_id=EngageOncallActionIds.service, + block_id=EngageOncallBlockIds.service, + placeholder="Select Service", + options=services, + ), + Input( + block_id=EngageOncallBlockIds.page, + label="Page", + element=Checkboxes( + options=[PlainOption(text="Page", value="Yes")], + action_id=EngageOncallActionIds.page, + ), + optional=True, + ), + ] + + modal = Modal( + title="Engage Oncall", + blocks=blocks, + submit="Engage", + close="Close", + callback_id=EngageOncallActions.submit, + private_metadata=context["subject"].json(), + ).build() + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_engage_oncall_submission_event(ack: AsyncAck) -> None: + """Handles engage oncall acknowledgment.""" + modal = Modal( + title="Engage Oncall", close="Close", blocks=[Section(text="Engaging oncall...")] + ).build() + await ack(response_action="update", view=modal) + + +async def handle_engage_oncall_submission_event( + client: AsyncWebClient, + body: dict, + user: DispatchUser, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the engage oncall submission""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + + oncall_service_external_id = form_data[EngageOncallBlockIds.service]["value"] + page = form_data.get(EngageOncallBlockIds.page, {"value": None})["value"] + + oncall_individual, oncall_service = incident_flows.incident_engage_oncall_flow( + user.email, + context["subject"].id, + oncall_service_external_id, + page=page, + db_session=db_session, + ) + + if not oncall_individual and not oncall_service: + message = "Could not engage oncall. Oncall service plugin not enabled." + + if not oncall_individual and oncall_service: + message = f"A member of {oncall_service.name} is already in the conversation." + + if oncall_individual and oncall_service: + message = f"You have successfully engaged {oncall_individual.name} from the {oncall_service.name} oncall rotation." + + modal = Modal(title="Engagement", blocks=[Section(text=message)], close="Close").build() + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + EngageOncallActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_engage_oncall_submission_event, lazy=[handle_engage_oncall_submission_event]) + + +async def handle_report_tactical_command( + ack: AsyncAck, + client: AsyncWebClient, + respond: AsyncRespond, + context: AsyncBoltContext, + db_session: Session, + body: dict, +) -> None: + """Handles the report tactical command.""" + await ack() + + if context["subject"].type == "case": + await respond( + text="Command is not available outside of incident channels.", response_type="ephemeral" + ) + return + + # we load the most recent tactical report + tactical_report = report_service.get_most_recent_by_incident_id_and_type( + db_session=db_session, + incident_id=context["subject"].id, + report_type=ReportTypes.tactical_report, + ) + + conditions = actions = needs = None + if tactical_report: + conditions = tactical_report.details.get("conditions") + actions = tactical_report.details.get("actions") + needs = tactical_report.details.get("needs") + + blocks = [ + Input( + label="Conditions", + element=PlainTextInput( + placeholder="Current incident conditions", initial_value=conditions, multiline=True + ), + block_id=ReportTacticalBlockIds.conditions, + ), + Input( + label="Actions", + element=PlainTextInput( + placeholder="Current incident actions", initial_value=actions, multiline=True + ), + block_id=ReportTacticalBlockIds.actions, + ), + Input( + label="Needs", + element=PlainTextInput( + placeholder="Current incident needs", initial_value=needs, multiline=True + ), + block_id=ReportTacticalBlockIds.needs, + ), + ] + + modal = Modal( + title="Tactical Report", + blocks=blocks, + submit="Create", + close="Close", + callback_id=ReportTacticalActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_report_tactical_submission_event(ack: AsyncAck) -> None: + """Handles report tactical acknowledgment.""" + modal = Modal( + title="Report Tactical", close="Close", blocks=[Section(text="Creating tactical report...")] + ).build() + await ack(response_action="update", view=modal) + + +async def handle_report_tactical_submission_event( + client: AsyncWebClient, + body: dict, + user: DispatchUser, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the report tactical submission""" + tactical_report_in = TacticalReportCreate( + conditions=form_data[ReportTacticalBlockIds.conditions], + actions=form_data[ReportTacticalBlockIds.actions], + needs=form_data[ReportTacticalBlockIds.needs], + ) + + report_flows.create_tactical_report( + user_email=user.email, + incident_id=context["subject"].id, + tactical_report_in=tactical_report_in, + organization_slug=context["subject"].organization_slug, + ) + modal = Modal( + title="Tactical Report", + blocks=[Section(text="Creating tactical report... Success!")], + close="Close", + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + ReportTacticalActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_report_tactical_submission_event, lazy=[handle_report_tactical_submission_event]) + + +async def handle_report_executive_command( + ack: AsyncAck, + body: dict, + client: AsyncWebClient, + respond: AsyncRespond, + context: AsyncBoltContext, + db_session: Session, +) -> None: + """Handles executive report command.""" + await ack() + + if context["subject"].type == "case": + await respond( + text="Command is not available outside of incident channels.", response_type="ephemeral" + ) + return + + executive_report = report_service.get_most_recent_by_incident_id_and_type( + db_session=db_session, + incident_id=context["subject"].id, + report_type=ReportTypes.executive_report, + ) + + current_status = overview = next_steps = None + if executive_report: + current_status = executive_report.details.get("current_status") + overview = executive_report.details.get("overview") + next_steps = executive_report.details.get("next_steps") + + blocks = [ + Input( + label="Current Status", + element=PlainTextInput( + placeholder="Current status", initial_value=current_status, multiline=True + ), + block_id=ReportExecutiveBlockIds.current_status, + ), + Input( + label="Overview", + element=PlainTextInput(placeholder="Overview", initial_value=overview, multiline=True), + block_id=ReportExecutiveBlockIds.overview, + ), + Input( + label="Next Steps", + element=PlainTextInput( + placeholder="Next steps", initial_value=next_steps, multiline=True + ), + block_id=ReportExecutiveBlockIds.next_steps, + ), + Context( + elements=[ + MarkdownText( + text=f"Use {context['config'].slack_command_update_notifications_group} to update the list of recipients of this report." + ) + ] + ), + ] + + modal = Modal( + title="Executive Report", + blocks=blocks, + submit="Create", + close="Close", + callback_id=ReportExecutiveActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_report_executive_submission_event(ack: AsyncAck) -> None: + """Handles executive submission acknowledgement.""" + modal = Modal( + title="Executive Report", + close="Close", + blocks=[Section(text="Creating executive report...")], + ).build() + await ack(response_action="update", view=modal) + + +async def handle_report_executive_submission_event( + client: AsyncWebClient, + body: dict, + user: DispatchUser, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the report executive submission""" + executive_report_in = ExecutiveReportCreate( + current_status=form_data[ReportExecutiveBlockIds.current_status], + overview=form_data[ReportExecutiveBlockIds.overview], + next_steps=form_data[ReportExecutiveBlockIds.next_steps], + ) + + report_flows.create_executive_report( + user_email=user.email, + incident_id=context["subject"].id, + executive_report_in=executive_report_in, + organization_slug=context["subject"].organization_slug, + ) + modal = Modal( + title="Executive Report", + blocks=[Section(text="Creating executive report... Success!")], + close="Close", + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + ReportExecutiveActions.submit, + middleware=[ + action_context_middleware, + db_middleware, + user_middleware, + modal_submit_middleware, + configuration_middleware, + ], +)(ack=ack_report_executive_submission_event, lazy=[handle_report_executive_submission_event]) + + +async def handle_update_incident_command( + ack: AsyncAck, + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + db_session: Session, +) -> None: + """Creates the incident update modal.""" + await ack() + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + blocks = [ + Context(elements=[MarkdownText(text="Use this form to update the incident's details.")]), + title_input(initial_value=incident.title), + description_input(initial_value=incident.description), + resolution_input(initial_value=incident.resolution), + incident_status_select(initial_option={"text": incident.status, "value": incident.status}), + project_select( + db_session=db_session, + initial_option={"text": incident.project.name, "value": incident.project.id}, + action_id=IncidentUpdateActions.project_select, + dispatch_action=True, + ), + incident_type_select( + db_session=db_session, + initial_option={ + "text": incident.incident_type.name, + "value": incident.incident_type.id, + }, + project_id=incident.project.id, + ), + incident_severity_select( + db_session=db_session, + initial_option={ + "text": incident.incident_severity.name, + "value": incident.incident_severity.id, + }, + project_id=incident.project.id, + ), + incident_priority_select( + db_session=db_session, + initial_option={ + "text": incident.incident_priority.name, + "value": incident.incident_priority.id, + }, + project_id=incident.project.id, + ), + tag_multi_select( + optional=True, + initial_options=[{"text": t.name, "value": t.name} for t in incident.tags], + ), + ] + + modal = Modal( + title="Update Incident", + blocks=blocks, + submit="Update", + close="Cancel", + callback_id=IncidentUpdateActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_incident_update_submission_event(ack: AsyncAck) -> None: + """Handles incident update submission event.""" + modal = Modal( + title="Incident Update", + close="Close", + blocks=[Section(text="Updating incident...")], + ).build() + await ack(response_action="update", view=modal) + + +async def handle_update_incident_submission_event( + body: dict, + client: AsyncWebClient, + user: DispatchUser, + context: AsyncBoltContext, + form_data: dict, +) -> None: + """Handles the update incident submission""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + db_session = refetch_db_session(context["subject"].organization_slug) + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + tags = [] + for t in form_data.get(IncidentUpdateBlockIds.tags_multi_select, []): + # we have to fetch as only the IDs are embedded in slack + tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) + tags.append(tag) + + incident_in = IncidentUpdate( + title=form_data[DefaultBlockIds.title_input], + description=form_data[DefaultBlockIds.description_input], + resolution=form_data[DefaultBlockIds.resolution_input], + incident_type={"name": form_data[DefaultBlockIds.incident_type_select]["name"]}, + incident_severity={"name": form_data[DefaultBlockIds.incident_severity_select]["name"]}, + incident_priority={"name": form_data[DefaultBlockIds.incident_priority_select]["name"]}, + status=form_data[DefaultBlockIds.incident_status_select]["name"], + tags=tags, + ) + + previous_incident = IncidentRead.from_orm(incident) + + # we currently don't allow users to update the incident's visibility, + # costs, terms, or duplicates via Slack, so we copy them over + incident_in.visibility = incident.visibility + incident_in.incident_costs = incident.incident_costs + incident_in.terms = incident.terms + incident_in.duplicates = incident.duplicates + + updated_incident = incident_service.update( + db_session=db_session, incident=incident, incident_in=incident_in + ) + + commander_email = updated_incident.commander.individual.email + reporter_email = updated_incident.reporter.individual.email + + incident_flows.incident_update_flow( + user.email, + commander_email, + reporter_email, + context["subject"].id, + previous_incident, + db_session=db_session, + ) + modal = Modal( + title="Incident Update", + close="Close", + blocks=[Section(text="Updating incident... Success!")], + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + +app.view( + IncidentUpdateActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_incident_update_submission_event, lazy=[handle_update_incident_submission_event]) + + +async def handle_report_incident_command( + ack: AsyncAck, + body: dict, + context: AsyncBoltContext, + db_session: Session, + client: AsyncWebClient, +) -> None: + """Handles the report incident command.""" + await ack() + + blocks = [ + Context( + elements=[ + MarkdownText( + text="If you suspect an incident and need help, please fill out this form to the best of your abilities." + ) + ] + ), + title_input(), + description_input(), + project_select( + db_session=db_session, + action_id=IncidentReportActions.project_select, + dispatch_action=True, + ), + ] + + modal = Modal( + title="Report Incident", + blocks=blocks, + submit="Report", + close="Cancel", + callback_id=IncidentReportActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +async def ack_report_incident_submission_event(ack: AsyncAck) -> None: + """Handles the report incident submission event acknowledgment.""" + modal = Modal( + title="Report Incident", + close="Close", + blocks=[Section(text="Creating incident resources...")], + ).build() + await ack(response_action="update", view=modal) + + +async def handle_report_incident_submission_event( + user: DispatchUser, + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + form_data: dict, +) -> None: + """Handles the report incident submission""" + # refetch session as we can't pass a db_session lazily, these could be moved to @background_task functions + # in the future + + # TODO: handle multiple organizations during submission + db_session = refetch_db_session(context["subject"].organization_slug) + + tags = [] + for t in form_data.get(DefaultBlockIds.tags_multi_select, []): + # we have to fetch as only the IDs are embedded in Slack + tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) + tags.append(tag) + + project = {"name": form_data[DefaultBlockIds.project_select]["name"]} + + incident_type = None + if form_data.get(DefaultBlockIds.incident_type_select): + incident_type = {"name": form_data[DefaultBlockIds.incident_type_select]["name"]} + + incident_priority = None + if form_data.get(DefaultBlockIds.incident_priority_select): + incident_priority = {"name": form_data[DefaultBlockIds.incident_priority_select]["name"]} + + incident_severity = None + if form_data.get(DefaultBlockIds.incident_severity_select): + incident_severity = {"name": form_data[DefaultBlockIds.incident_severity_select]["name"]} + + incident_in = IncidentCreate( + title=form_data[DefaultBlockIds.title_input], + description=form_data[DefaultBlockIds.description_input], + incident_type=incident_type, + incident_priority=incident_priority, + incident_severity=incident_severity, + project=project, + reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), + tags=tags, + ) + + blocks = [ + Section(text="Creating your incident..."), + ] + + modal = Modal(title="Incident Report", blocks=blocks, close="Close").build() + + result = await client.views_update( + view_id=body["view"]["id"], + trigger_id=body["trigger_id"], + view=modal, + ) + + # Create the incident + incident = incident_service.create(db_session=db_session, incident_in=incident_in) + + blocks = [ + Section( + text="This is a confirmation that you have reported an incident with the following information. You will be invited to an incident Slack conversation shortly." + ), + Section(text=f"*Title*\n {incident.title}"), + Section(text=f"*Description*\n {incident.description}"), + Section( + fields=[ + MarkdownText( + text=f"*Commander*\n<{incident.commander.individual.weblink}|{incident.commander.individual.name}>" + ), + MarkdownText(text=f"*Type*\n {incident.incident_type.name}"), + MarkdownText(text=f"*Severity*\n {incident.incident_severity.name}"), + MarkdownText(text=f"*Priority*\n {incident.incident_priority.name}"), + ] + ), + ] + modal = Modal(title="Incident Report", blocks=blocks, close="Close").build() + + result = await client.views_update( + view_id=result["view"]["id"], + hash=result["view"]["hash"], + trigger_id=result["trigger_id"], + view=modal, + ) + + incident_flows.incident_create_flow( + incident_id=incident.id, + db_session=db_session, + organization_slug=incident.project.organization.slug, + ) + + +app.view( + IncidentReportActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +)(ack=ack_report_incident_submission_event, lazy=[handle_report_incident_submission_event]) + + +@app.action( + IncidentReportActions.project_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_report_incident_project_select_action( + ack: AsyncAck, + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + db_session: Session, +) -> None: + await ack() + values = body["view"]["state"]["values"] + + project_id = values[DefaultBlockIds.project_select][IncidentReportActions.project_select][ + "selected_option" + ]["value"] + + project = project_service.get(db_session=db_session, project_id=project_id) + + blocks = [ + Context(elements=[MarkdownText(text="Use this form to update the incident's details.")]), + title_input(), + description_input(), + project_select( + db_session=db_session, + action_id=IncidentReportActions.project_select, + dispatch_action=True, + ), + incident_type_select(db_session=db_session, project_id=project.id, optional=True), + incident_severity_select(db_session=db_session, project_id=project.id, optional=True), + incident_priority_select(db_session=db_session, project_id=project.id, optional=True), + tag_multi_select(optional=True), + ] + + modal = Modal( + title="Report Incident", + blocks=blocks, + submit="Report", + close="Cancel", + callback_id=IncidentReportActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) diff --git a/src/dispatch/plugins/dispatch_slack/modals/__init__.py b/src/dispatch/plugins/dispatch_slack/incident/messages.py similarity index 100% rename from src/dispatch/plugins/dispatch_slack/modals/__init__.py rename to src/dispatch/plugins/dispatch_slack/incident/messages.py diff --git a/src/dispatch/plugins/dispatch_slack/menus.py b/src/dispatch/plugins/dispatch_slack/menus.py deleted file mode 100644 index 2478c3e84332..000000000000 --- a/src/dispatch/plugins/dispatch_slack/menus.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -import logging - -from starlette.requests import Request -from slack_sdk.web.client import WebClient - -from dispatch.database.core import SessionLocal -from dispatch.database.service import search_filter_sort_paginate -from dispatch.incident import service as incident_service -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.plugins.dispatch_slack.modals.common import parse_submitted_form -from dispatch.plugins.dispatch_slack.modals.incident.enums import IncidentBlockId - -from .decorators import ( - get_organization_scope_from_channel_id, - get_organization_scope_from_slug, -) - -log = logging.getLogger(__name__) - - -async def handle_slack_menu(*, config, client: WebClient, request: Request, organization: str): - """Handles slack menu message.""" - # We resolve the user's email - user_id = request["user"]["id"] - user_email = await dispatch_slack_service.get_user_email_async(client, user_id) - - request["user"]["email"] = user_email - - # When there are no exceptions within the dialog submission, your app must respond with 200 OK with an empty body. - view_data = request["view"] - view_data["private_metadata"] = json.loads(view_data["private_metadata"]) - query_str = request.get("value") - - incident_id = view_data["private_metadata"].get("incident_id") - channel_id = view_data["private_metadata"].get("channel_id") - action_id = request["action_id"] - - db_session = get_organization_scope_from_channel_id(channel_id=channel_id) - if not db_session: - # Command (e.g. report_incident) run from a non-incident channel. - db_session = get_organization_scope_from_slug(organization) - - f = menu_functions(action_id) - menu = f(db_session, user_id, user_email, channel_id, incident_id, query_str, request) - db_session.close() - return menu - - -def menu_functions(action_id: str): - """Handles all menu requests.""" - menu_mappings = {IncidentBlockId.tags: get_tags} - - for key in menu_mappings.keys(): - if key in action_id: - return menu_mappings[key] - - raise Exception(f"No menu function found. actionId: {action_id}") - - -def get_tags( - db_session: SessionLocal, - user_id: int, - user_email: str, - channel_id: str, - incident_id: str, - query_str: str, - request: Request, -): - """Fetches tags based on the current query.""" - # use the project from the incident if available - filter_spec = {} - if incident_id: - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - # scope to current incident project - filter_spec = { - "and": [{"model": "Project", "op": "==", "field": "id", "value": incident.project.id}] - } - - submitted_form = request.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - project = parsed_form_data.get(IncidentBlockId.project) - - if project: - filter_spec = { - "and": [{"model": "Project", "op": "==", "field": "name", "value": project["value"]}] - } - - # attempt to filter by tag type - if "/" in query_str: - tag_type, tag_name = query_str.split("/") - type_filter = {"model": "TagType", "op": "==", "field": "name", "value": tag_type} - - if filter_spec.get("and"): - filter_spec["and"].append(type_filter) - else: - filter_spec = {"and": [type_filter]} - - if not tag_name: - query_str = None - - tags = search_filter_sort_paginate( - db_session=db_session, model="Tag", query_str=query_str, filter_spec=filter_spec - ) - else: - tags = search_filter_sort_paginate( - db_session=db_session, model="Tag", query_str=query_str, filter_spec=filter_spec - ) - - options = [] - for t in tags["items"]: - options.append( - { - "text": {"type": "plain_text", "text": f"{t.tag_type.name}/{t.name}"}, - "value": str( - t.id - ), # NOTE slack doesn't not accept int's as values (fails silently) - } - ) - - return {"options": options} diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index 1e91d171d05a..79eb8f18e338 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -6,7 +6,8 @@ """ import logging from typing import List, Optional -from jinja2 import Template + +from blockkit import Section, Divider, Button, Context, MarkdownText, PlainText, Actions from dispatch.messaging.strings import ( EVERGREEN_REMINDER_DESCRIPTION, @@ -17,170 +18,9 @@ render_message_template, ) -from .config import SlackConfiguration - - log = logging.getLogger(__name__) -def get_incident_conversation_command_message(config: SlackConfiguration, command_string: str): - command_messages = { - config.slack_command_run_workflow: { - "response_type": "ephemeral", - "text": "Opening a modal to run a workflow...", - }, - config.slack_command_report_tactical: { - "response_type": "ephemeral", - "text": "Opening a dialog to write a tactical report...", - }, - config.slack_command_list_tasks: { - "response_type": "ephemeral", - "text": "Fetching the list of incident tasks...", - }, - config.slack_command_list_my_tasks: { - "response_type": "ephemeral", - "text": "Fetching your incident tasks...", - }, - config.slack_command_list_participants: { - "response_type": "ephemeral", - "text": "Fetching the list of incident participants...", - }, - config.slack_command_assign_role: { - "response_type": "ephemeral", - "text": "Opening a dialog to assign a role to a participant...", - }, - config.slack_command_update_incident: { - "response_type": "ephemeral", - "text": "Opening a dialog to update incident information...", - }, - config.slack_command_update_participant: { - "response_type": "ephemeral", - "text": "Opening a dialog to update participant information...", - }, - config.slack_command_engage_oncall: { - "response_type": "ephemeral", - "text": "Opening a dialog to engage an oncall person...", - }, - config.slack_command_list_resources: { - "response_type": "ephemeral", - "text": "Fetching the list of incident resources...", - }, - config.slack_command_report_incident: { - "response_type": "ephemeral", - "text": "Opening a dialog to report an incident...", - }, - config.slack_command_report_executive: { - "response_type": "ephemeral", - "text": "Opening a dialog to write an executive report...", - }, - config.slack_command_update_notifications_group: { - "response_type": "ephemeral", - "text": "Opening a dialog to update the membership of the notifications group...", - }, - config.slack_command_add_timeline_event: { - "response_type": "ephemeral", - "text": "Opening a dialog to add an event to the incident timeline...", - }, - config.slack_command_list_incidents: { - "response_type": "ephemeral", - "text": "Fetching the list of incidents...", - }, - config.slack_command_list_workflows: { - "response_type": "ephemeral", - "text": "Fetching the list of workflows...", - }, - } - - return command_messages.get(command_string, f"Running command... {command_string}") - - -INCIDENT_CONVERSATION_COMMAND_RUN_IN_NONINCIDENT_CONVERSATION = """ -I see you tried to run `{{command}}` in an non-incident conversation. -Incident-specifc commands can only be run in incident conversations.""".replace( - "\n", " " -).strip() - -INCIDENT_CONVERSATION_COMMAND_RUN_BY_NON_PRIVILEGED_USER = """ -I see you tried to run `{{command}}`. -This is a sensitive command and cannot be run with the incident role you are currently assigned.""".replace( - "\n", " " -).strip() - -INCIDENT_CONVERSATION_COMMAND_RUN_IN_CONVERSATION_WHERE_BOT_NOT_PRESENT = """ -Looks like you tried to run `{{command}}` in a conversation where the Dispatch bot is not present. -Add the bot to your conversation or run the command in one of the following conversations: {{conversations}}""".replace( - "\n", " " -).strip() - - -def create_command_run_by_non_privileged_user_message(command: str): - """Creates a message for when a sensitive command is run by a non privileged user.""" - return { - "response_type": "ephemeral", - "text": Template(INCIDENT_CONVERSATION_COMMAND_RUN_BY_NON_PRIVILEGED_USER).render( - command=command - ), - } - - -def create_command_run_in_nonincident_conversation_message(command: str): - """Creates a message for when an incident specific command is run in an nonincident conversation.""" - return { - "response_type": "ephemeral", - "text": Template(INCIDENT_CONVERSATION_COMMAND_RUN_IN_NONINCIDENT_CONVERSATION).render( - command=command - ), - } - - -def create_command_run_in_conversation_where_bot_not_present_message( - command: str, conversations: List -): - """Creates a message for when a non-incident specific command is run in a conversation where the Dispatch bot is not present.""" - conversations = (", ").join([f"#{conversation}" for conversation in conversations]) - return { - "response_type": "ephemeral", - "text": Template( - INCIDENT_CONVERSATION_COMMAND_RUN_IN_CONVERSATION_WHERE_BOT_NOT_PRESENT - ).render(command=command, conversations=conversations), - } - - -def create_incident_reported_confirmation_message( - title: str, description: str, incident_type: str, incident_priority: str -): - """Creates an incident reported confirmation message.""" - return [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "Security Incident Reported", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "This is a confirmation that you have reported a security incident with the following information. You'll get invited to a Slack conversation soon.", - }, - }, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Incident Title*: {title}"}}, - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Incident Description*: {description}"}, - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Incident Type*: {incident_type}"}, - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Incident Priority*: {incident_priority}"}, - }, - ] - - def get_template(message_type: MessageType): """Fetches the correct template based on message type.""" template_map = { @@ -215,8 +55,7 @@ def format_default_text(item: dict): def default_notification(items: list): """Creates blocks for a default notification.""" - blocks = [] - blocks.append({"type": "divider"}) + blocks = [Divider()] for item in items: if isinstance(item, list): # handle case where we are passing multiple grouped items blocks += default_notification(item) @@ -225,34 +64,34 @@ def default_notification(items: list): continue if item.get("type"): - block = { - "type": item["type"], - } if item["type"] == "context": - block.update({"elements": [{"type": "mrkdwn", "text": format_default_text(item)}]}) + blocks.append(Context(elements=[MarkdownText(text=format_default_text(item))])) else: - block.update({"text": {"type": "plain_text", "text": format_default_text(item)}}) - blocks.append(block) + blocks.append(PlainText(text=format_default_text(item))) else: - block = { - "type": "section", - "text": {"type": "mrkdwn", "text": format_default_text(item)}, - } - blocks.append(block) + blocks.append(Section(text=format_default_text(item))) if item.get("buttons"): - block = {"type": "actions", "elements": []} + elements = [] for button in item["buttons"]: if button.get("button_text") and button.get("button_value"): - block["elements"].append( - { - "action_id": button["button_action"], - "type": "button", - "text": {"type": "plain_text", "text": button["button_text"]}, - "value": button["button_value"], - } - ) - blocks.append(block) + if button.get("button_url"): + element = Button( + action_id=button["button_action"], + text=button["button_text"], + value=button["button_value"], + url=button["button_url"], + ) + else: + element = Button( + action_id=button["button_action"], + text=button["button_text"], + value=button["button_value"], + url=button["button_url"], + ) + + elements.append(element) + blocks.append(Actions(elements=elements)) return blocks @@ -274,11 +113,14 @@ def create_message_blocks( blocks = [] if description: # include optional description text (based on message type) - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": description}}) + blocks.append(Section(text=description)) for item in items: - rendered_items = render_message_template(message_template, **item) - blocks += template_func(rendered_items) + if message_template: + rendered_items = render_message_template(message_template, **item) + blocks += template_func(rendered_items) + else: + blocks += template_func(**item)["blocks"] blocks_grouped = [] if items: diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py new file mode 100644 index 000000000000..cce1af4a45a6 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -0,0 +1,250 @@ +from typing import Optional, Callable + +from slack_bolt.async_app import AsyncBoltContext, AsyncRespond +from slack_sdk.web.async_client import AsyncWebClient +from sqlalchemy.orm.session import Session + +from dispatch.auth import service as user_service +from dispatch.auth.models import UserRegister, DispatchUser +from dispatch.conversation import service as conversation_service +from dispatch.conversation.models import Conversation +from dispatch.database.core import SessionLocal, engine, sessionmaker +from dispatch.organization import service as organization_service +from dispatch.participant import service as participant_service +from dispatch.participant_role.enums import ParticipantRoleType +from dispatch.plugin import service as plugin_service + +from .models import SubjectMetadata +from .exceptions import ContextError, RoleError + + +def resolve_conversation_from_context( + channel_id: str, message_ts: Optional[str] = None +) -> Optional[Conversation]: + """Attempts to resolve a conversation based on the channel id or message_ts.""" + db_session = SessionLocal() + organization_slugs = [o.slug for o in organization_service.get_all(db_session=db_session)] + db_session.close() + + conversation = None + for slug in organization_slugs: + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{slug}", + } + ) + + # we close this later + scoped_db_session = sessionmaker(bind=schema_engine)() + conversation = conversation_service.get_by_channel_id_ignoring_channel_type( + db_session=scoped_db_session, channel_id=channel_id + ) + + if conversation: + break + + return conversation + + +async def shortcut_context_middleware(context: AsyncBoltContext, next: Callable) -> None: + """Attempts to determine the current context of the event.""" + context.update({"subject": SubjectMetadata(channel_id=context["channel_id"])}) + await next() + + +async def button_context_middleware( + payload: dict, context: AsyncBoltContext, next: Callable +) -> None: + """Attempt to determine the current context of the event.""" + context.update({"subject": SubjectMetadata.parse_raw(payload["value"])}) + await next() + + +async def action_context_middleware(body: dict, context: AsyncBoltContext, next: Callable) -> None: + """Attempt to determine the current context of the event.""" + context.update({"subject": SubjectMetadata.parse_raw(body["view"]["private_metadata"])}) + await next() + + +async def message_context_middleware(context: AsyncBoltContext, next: Callable) -> None: + """Attemps to determine the current context of the event.""" + conversation = resolve_conversation_from_context(context["channel_id"]) + if conversation: + context.update( + { + "subject": SubjectMetadata( + type="incident", + id=conversation.incident.id, + organization_slug=conversation.incident.project.organization.slug, + project_id=conversation.incident.project.id, + ), + "db_session": Session.object_session(conversation), + } + ) + else: + raise ContextError("Unable to determine context for message.") + + await next() + + +async def restricted_command_middleware( + context: AsyncBoltContext, db_session, user: DispatchUser, next: Callable, payload: dict +): + """Rejects commands from unauthorized individuals.""" + allowed_roles = [ParticipantRoleType.incident_commander, ParticipantRoleType.scribe] + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=context["subject"].id, email=user.email + ) + + # if any required role is active, allow command + for active_role in participant.active_roles: + for allowed_role in allowed_roles: + if active_role.role == allowed_role: + return await next() + + raise RoleError( + f"Participant does not have permission to run `{payload['command']}`. Roles with permission: {','.join([r.name for r in allowed_roles])}", + ) + + +async def user_middleware( + body: dict, + payload: dict, + db_session: Session, + client: AsyncWebClient, + context: AsyncBoltContext, + next: Callable, +) -> None: + """Attempts to determine the user making the request.""" + + # for modals + if body.get("user"): + user_id = body["user"]["id"] + + # for messages + if payload.get("user"): + user_id = payload["user"] + + # for commands + if payload.get("user_id"): + user_id = payload["user_id"] + + if not user_id: + raise ContextError("Unable to determine user from context.") + + email = (await client.users_info(user=user_id))["user"]["profile"]["email"] + context["user"] = user_service.get_or_create( + db_session=db_session, + organization=context["subject"].organization_slug, + user_in=UserRegister(email=email), + ) + await next() + + +async def modal_submit_middleware(body: dict, context: AsyncBoltContext, next: Callable) -> None: + """Parses view data into a reasonable data struct.""" + parsed_data = {} + state_elem = body["view"].get("state") + state_values = state_elem.get("values") + + for state in state_values: + state_key_value_pair = state_values[state] + + for elem_key in state_key_value_pair: + elem_key_value_pair = state_values[state][elem_key] + + if elem_key_value_pair.get("selected_option") and elem_key_value_pair.get( + "selected_option" + ).get("value"): + parsed_data[state] = { + "name": elem_key_value_pair.get("selected_option").get("text").get("text"), + "value": elem_key_value_pair.get("selected_option").get("value"), + } + elif elem_key_value_pair.get("selected_user"): + parsed_data[state] = { + "name": "user", + "value": elem_key_value_pair.get("selected_user"), + } + elif "selected_options" in elem_key_value_pair.keys(): + name = "No option selected" + value = "" + + if elem_key_value_pair.get("selected_options"): + options = [] + for selected in elem_key_value_pair["selected_options"]: + name = selected.get("text").get("text") + value = selected.get("value") + options.append({"name": name, "value": value}) + + parsed_data[state] = options + elif elem_key_value_pair.get("selected_date"): + parsed_data[state] = elem_key_value_pair.get("selected_date") + else: + parsed_data[state] = elem_key_value_pair.get("value") + + context["form_data"] = parsed_data + await next() + + +# NOTE we don't need to handle cases because commands are not available in threads. +async def command_context_middleware( + context: AsyncBoltContext, payload: dict, next: Callable, respond: AsyncRespond +) -> None: + conversation = resolve_conversation_from_context(channel_id=context["channel_id"]) + if conversation: + context.update( + { + "subject": SubjectMetadata( + type="incident", + id=conversation.incident.id, + organization_slug=conversation.incident.project.organization.slug, + project_id=conversation.incident.project.id, + ) + } + ) + else: + raise ContextError( + f"Sorry, I can't determine the correct context to run the command `{payload['command']}`. Are you running this command in an incident channel?" + ) + await next() + + +async def db_middleware(context: AsyncBoltContext, next: Callable): + if context.get("db_session"): + return await next() + + if not context.get("subject"): + db_session = SessionLocal() + slug = organization_service.get_default(db_session=db_session).slug + context.update({"subject": SubjectMetadata(organization_slug=slug)}) + db_session.close() + else: + slug = context["subject"].organization_slug + + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{slug}", + } + ) + context["db_session"] = sessionmaker(bind=schema_engine)() + await next() + + +async def configuration_middleware(context: AsyncBoltContext, next: Callable): + if context.get("config"): + return await next() + + if not context.get("subject") and not context.get("db_session"): + return await next() + + plugin = plugin_service.get_active_instance( + db_session=context["db_session"], + project_id=context["subject"].project_id, + plugin_type="conversation", + ) + + if not plugin: + return await next() + + context["config"] = plugin.configuration + await next() diff --git a/src/dispatch/plugins/dispatch_slack/modals/common.py b/src/dispatch/plugins/dispatch_slack/modals/common.py deleted file mode 100644 index da74722fa7b1..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/common.py +++ /dev/null @@ -1,37 +0,0 @@ -def parse_submitted_form(view_data: dict): - """Parse the submitted data and return important / required fields for Dispatch to create an incident.""" - parsed_data = {} - state_elem = view_data.get("state") - state_values = state_elem.get("values") - - for state in state_values: - state_key_value_pair = state_values[state] - - for elem_key in state_key_value_pair: - elem_key_value_pair = state_values[state][elem_key] - - if elem_key_value_pair.get("selected_option") and elem_key_value_pair.get( - "selected_option" - ).get("value"): - parsed_data[state] = { - "name": elem_key_value_pair.get("selected_option").get("text").get("text"), - "value": elem_key_value_pair.get("selected_option").get("value"), - } - elif "selected_options" in elem_key_value_pair.keys(): - name = "No option selected" - value = "" - - if elem_key_value_pair.get("selected_options"): - options = [] - for selected in elem_key_value_pair["selected_options"]: - name = selected.get("text").get("text") - value = selected.get("value") - options.append({"name": name, "value": value}) - - parsed_data[state] = options - elif elem_key_value_pair.get("selected_date"): - parsed_data[state] = elem_key_value_pair.get("selected_date") - else: - parsed_data[state] = elem_key_value_pair.get("value") - - return parsed_data diff --git a/src/dispatch/plugins/dispatch_slack/modals/feedback/__init__.py b/src/dispatch/plugins/dispatch_slack/modals/feedback/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/dispatch/plugins/dispatch_slack/modals/feedback/handlers.py b/src/dispatch/plugins/dispatch_slack/modals/feedback/handlers.py deleted file mode 100644 index 4658eeb50dc4..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/feedback/handlers.py +++ /dev/null @@ -1,90 +0,0 @@ -from dispatch.incident import service as incident_service -from dispatch.participant import service as participant_service -from dispatch.feedback import service as feedback_service -from dispatch.feedback.models import FeedbackCreate -from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration - -from dispatch.plugins.dispatch_slack.decorators import slack_background_task -from dispatch.plugins.dispatch_slack.service import ( - send_message, - send_ephemeral_message, - open_modal_with_user, -) -from dispatch.plugins.dispatch_slack.modals.common import parse_submitted_form - -from .views import rating_feedback_view, RatingFeedbackBlockId - - -@slack_background_task -def create_rating_feedback_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for rating and providing feedback about an incident.""" - trigger_id = action["trigger_id"] - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - if not incident: - message = ( - "Sorry, you cannot submit feedback about this incident. The incident does not exist." - ) - send_ephemeral_message(slack_client, channel_id, user_id, message) - else: - modal_create_template = rating_feedback_view(incident=incident, channel_id=channel_id) - - open_modal_with_user( - client=slack_client, trigger_id=trigger_id, modal=modal_create_template - ) - - -@slack_background_task -def rating_feedback_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Adds rating and feeback to incident based on submitted form data.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - feedback = parsed_form_data.get(RatingFeedbackBlockId.feedback) - rating = parsed_form_data.get(RatingFeedbackBlockId.rating, {}).get("value") - - feedback_in = FeedbackCreate( - rating=rating, feedback=feedback, project=incident.project, incident=incident - ) - feedback = feedback_service.create(db_session=db_session, feedback_in=feedback_in) - - incident.feedback.append(feedback) - - # we only really care if this exists, if it doesn't then flag is false - if not parsed_form_data.get(RatingFeedbackBlockId.anonymous): - participant.feedback.append(feedback) - db_session.add(participant) - - db_session.add(incident) - db_session.commit() - - send_message( - client=slack_client, - conversation_id=user_id, - text="Thank you for your feedback!", - ) diff --git a/src/dispatch/plugins/dispatch_slack/modals/feedback/views.py b/src/dispatch/plugins/dispatch_slack/modals/feedback/views.py deleted file mode 100644 index f44ff9badad9..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/feedback/views.py +++ /dev/null @@ -1,97 +0,0 @@ -import json - -from dispatch.enums import DispatchEnum -from dispatch.incident.models import Incident -from dispatch.feedback.enums import FeedbackRating - - -class RatingFeedbackBlockId(DispatchEnum): - anonymous = "anonymous_field" - feedback = "feedback_field" - rating = "rating_field" - - -class RatingFeedbackCallbackId(DispatchEnum): - submit_form = "rating_feedback_submit_form" - - -def rating_feedback_view(incident: Incident, channel_id: str): - """Builds all blocks required to rate and provide feedback about an incident.""" - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Incident Feedback"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "plain_text", - "text": "Use this form to rate your experience and provide feedback about the incident.", - } - ], - }, - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": RatingFeedbackCallbackId.submit_form, - "private_metadata": json.dumps({"incident_id": str(incident.id), "channel_id": channel_id}), - } - - rating_picker_options = [] - for rating in FeedbackRating: - rating_picker_options.append( - {"text": {"type": "plain_text", "text": rating}, "value": rating} - ) - - rating_picker_block = { - "type": "input", - "block_id": RatingFeedbackBlockId.rating, - "label": {"type": "plain_text", "text": "Rate your experience"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a rating"}, - "options": rating_picker_options, - }, - "optional": False, - } - modal_template["blocks"].append(rating_picker_block) - - feedback_block = { - "type": "input", - "block_id": RatingFeedbackBlockId.feedback, - "label": {"type": "plain_text", "text": "Give us feedback"}, - "element": { - "type": "plain_text_input", - "action_id": RatingFeedbackBlockId.feedback, - "placeholder": { - "type": "plain_text", - "text": "How would you describe your experience?", - }, - "multiline": True, - }, - "optional": False, - } - modal_template["blocks"].append(feedback_block) - - anonymous_checkbox_block = { - "type": "input", - "block_id": RatingFeedbackBlockId.anonymous, - "label": { - "type": "plain_text", - "text": "Check the box if you wish to provide your feedback anonymously", - }, - "element": { - "type": "checkboxes", - "action_id": RatingFeedbackBlockId.anonymous, - "options": [ - { - "value": "anonymous", - "text": {"type": "plain_text", "text": "Anonymize my feedback"}, - }, - ], - }, - "optional": True, - } - modal_template["blocks"].append(anonymous_checkbox_block) - - return modal_template diff --git a/src/dispatch/plugins/dispatch_slack/modals/incident/__init__.py b/src/dispatch/plugins/dispatch_slack/modals/incident/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/dispatch/plugins/dispatch_slack/modals/incident/enums.py b/src/dispatch/plugins/dispatch_slack/modals/incident/enums.py deleted file mode 100644 index 52eac6c1f1ba..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/incident/enums.py +++ /dev/null @@ -1,58 +0,0 @@ -from dispatch.enums import DispatchEnum - - -# report + update blocks -class IncidentBlockId(DispatchEnum): - description = "description_field" - priority = "incident_priority_field" - project = "project_field" - resolution = "resolution_field" - severity = "incident_severity_field" - status = "status_field" - tags = "tags_select_field" - title = "title_field" - type = "incident_type_field" - - -# report incident -class ReportIncidentCallbackId(DispatchEnum): - submit_form = "report_incident_submit_form" - update_view = "report_incident_update_view" - - -# update incident -class UpdateIncidentCallbackId(DispatchEnum): - submit_form = "update_incident_submit_form" - - -# update participant -class UpdateParticipantBlockId(DispatchEnum): - reason_added = "reason_added_field" - participant = "selected_participant_field" - - -class UpdateParticipantCallbackId(DispatchEnum): - submit_form = "update_participant_submit_form" - update_view = "update_participant_update_view" - - -# update notification -class UpdateNotificationsGroupBlockId(DispatchEnum): - update_members = "update_members_field" - - -class UpdateNotificationsGroupCallbackId(DispatchEnum): - submit_form = "update_notifications_group_submit_form" - - -# add timeline -class AddTimelineEventBlockId(DispatchEnum): - date = "date_field" - hour = "hour_field" - minute = "minute_field" - timezone = "timezone_field" - description = "description_field" - - -class AddTimelineEventCallbackId(DispatchEnum): - submit_form = "add_timeline_event_submit_form" diff --git a/src/dispatch/plugins/dispatch_slack/modals/incident/fields.py b/src/dispatch/plugins/dispatch_slack/modals/incident/fields.py deleted file mode 100644 index 7b0c4bfdb77d..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/incident/fields.py +++ /dev/null @@ -1,315 +0,0 @@ -import logging -from typing import List - -from sqlalchemy.orm import Session - -from dispatch.incident.enums import IncidentStatus -from dispatch.incident.models import Incident -from dispatch.incident.priority import service as incident_priority_service -from dispatch.incident.priority.models import IncidentPriority -from dispatch.incident.severity import service as incident_severity_service -from dispatch.incident.severity.models import IncidentSeverity -from dispatch.incident.type import service as incident_type_service -from dispatch.incident.type.models import IncidentType -from dispatch.participant.models import Participant -from dispatch.project import service as project_service -from dispatch.tag.models import Tag - -from .enums import ( - IncidentBlockId, - ReportIncidentCallbackId, - UpdateParticipantBlockId, - UpdateParticipantCallbackId, -) - - -log = logging.getLogger(__name__) - - -def option_from_template(text: str, value: str): - """Helper function which generates the option block for modals / views""" - return {"text": {"type": "plain_text", "text": str(text), "emoji": True}, "value": str(value)} - - -def status_select_block(initial_option: str = None): - """Builds the incident status select block""" - status_options = [option_from_template(text=x.value, value=x.value) for x in IncidentStatus] - block = { - "block_id": IncidentBlockId.status, - "type": "input", - "label": {"type": "plain_text", "text": "Status"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Status"}, - "options": status_options, - }, - } - if initial_option: - block["element"].update( - {"initial_option": option_from_template(text=initial_option, value=initial_option)} - ) - - return block - - -def incident_type_select_block( - db_session: Session, initial_option: IncidentType = None, project_id: int = None -): - """Builds the incident type select block.""" - incident_type_options = [] - for incident_type in incident_type_service.get_all_enabled( - db_session=db_session, project_id=project_id - ): - incident_type_options.append( - option_from_template(text=incident_type.name, value=incident_type.name) - ) - block = { - "block_id": IncidentBlockId.type, - "type": "input", - "label": {"type": "plain_text", "text": "Type"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Type"}, - "options": incident_type_options, - }, - } - - if initial_option: - block["element"].update( - { - "initial_option": option_from_template( - text=initial_option.name, value=initial_option.name - ) - } - ) - - return block - - -def incident_severity_select_block( - db_session: Session, initial_option: IncidentSeverity = None, project_id: int = None -): - """Builds the incident severity select block.""" - incident_severity_options = [] - for incident_severity in incident_severity_service.get_all_enabled( - db_session=db_session, project_id=project_id - ): - incident_severity_options.append( - option_from_template(text=incident_severity.name, value=incident_severity.name) - ) - - block = { - "block_id": IncidentBlockId.severity, - "type": "input", - "label": {"type": "plain_text", "text": "Severity", "emoji": True}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Severity"}, - "options": incident_severity_options, - }, - } - - if initial_option: - block["element"].update( - { - "initial_option": option_from_template( - text=initial_option.name, value=initial_option.name - ) - } - ) - - return block - - -def incident_priority_select_block( - db_session: Session, initial_option: IncidentPriority = None, project_id: int = None -): - """Builds the incident priority select block.""" - incident_priority_options = [] - for incident_priority in incident_priority_service.get_all_enabled( - db_session=db_session, project_id=project_id - ): - incident_priority_options.append( - option_from_template(text=incident_priority.name, value=incident_priority.name) - ) - - block = { - "block_id": IncidentBlockId.priority, - "type": "input", - "label": {"type": "plain_text", "text": "Priority", "emoji": True}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Priority"}, - "options": incident_priority_options, - }, - } - - if initial_option: - block["element"].update( - { - "initial_option": option_from_template( - text=initial_option.name, value=initial_option.name - ) - } - ) - - return block - - -def project_select_block(db_session: Session, initial_option: dict = None): - """Builds the incident project select block.""" - project_options = [] - for project in project_service.get_all(db_session=db_session): - project_options.append(option_from_template(text=project.name, value=project.name)) - - block = { - "block_id": IncidentBlockId.project, - "type": "input", - "dispatch_action": True, - "label": { - "text": "Project", - "type": "plain_text", - }, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Project"}, - "options": project_options, - "action_id": ReportIncidentCallbackId.update_view, - }, - } - - if initial_option: - block["element"].update( - { - "initial_option": option_from_template( - text=initial_option.name, value=initial_option.name - ) - } - ) - return block - - -def tag_multi_select_block( - initial_options: List[Tag] = None, -): - """Builds the incident tag multi select block.""" - block = { - "block_id": IncidentBlockId.tags, - "type": "input", - "optional": True, - "label": {"type": "plain_text", "text": "Tags"}, - "element": { - "action_id": IncidentBlockId.tags, - "type": "multi_external_select", - "placeholder": {"type": "plain_text", "text": "Select related tags"}, - "min_query_length": 3, - }, - } - - if initial_options: - block["element"].update( - { - "initial_options": [ - option_from_template(text=f"{t.tag_type.name}/{t.name}", value=t.id) - for t in initial_options - ] - } - ) - - return block - - -def title_input_block(initial_value: str = None): - """Builds a valid incident title input.""" - block = { - "block_id": IncidentBlockId.title, - "type": "input", - "label": {"type": "plain_text", "text": "Title"}, - "element": { - "type": "plain_text_input", - "placeholder": { - "type": "plain_text", - "text": "A brief explanatory title. You can change this later.", - }, - }, - } - - if initial_value: - block["element"].update({"initial_value": initial_value}) - - return block - - -def description_input_block(initial_value: str = None): - """Builds a valid incident description input.""" - block = { - "block_id": IncidentBlockId.description, - "type": "input", - "label": {"type": "plain_text", "text": "Description"}, - "element": { - "type": "plain_text_input", - "placeholder": { - "type": "plain_text", - "text": "A summary of what you know so far. It's all right if this is incomplete.", - }, - "multiline": True, - }, - } - - if initial_value: - block["element"].update({"initial_value": initial_value}) - - return block - - -def resolution_input_block(initial_value: str = None): - """Builds a valid incident resolution input.""" - block = { - "block_id": IncidentBlockId.resolution, - "type": "input", - "label": {"type": "plain_text", "text": "Resolution"}, - "element": { - "type": "plain_text_input", - "placeholder": { - "type": "plain_text", - "text": "Description of the actions taken to resolve the incident.", - }, - "multiline": True, - }, - } - - if initial_value: - block["element"].update({"initial_value": initial_value}) - - return block - - -def participants_select_block(incident: Incident, initial_option: Participant = None): - """Builds a static select with all current participants.""" - participant_options = [] - for p in incident.participants: - participant_options.append(option_from_template(text=p.individual.name, value=p.id)) - - block = { - "block_id": UpdateParticipantBlockId.participant, - "type": "input", - "dispatch_action": True, - "label": {"type": "plain_text", "text": "Participant"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select Participant"}, - "options": participant_options, - "action_id": UpdateParticipantCallbackId.update_view, - }, - } - - if initial_option: - block["element"].update( - { - "initial_option": option_from_template( - text=initial_option.individual.name, value=initial_option.id - ) - } - ) - - return block diff --git a/src/dispatch/plugins/dispatch_slack/modals/incident/handlers.py b/src/dispatch/plugins/dispatch_slack/modals/incident/handlers.py deleted file mode 100644 index 094223e7962a..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/incident/handlers.py +++ /dev/null @@ -1,453 +0,0 @@ -import pytz -from datetime import datetime - -from dispatch.database.core import SessionLocal -from dispatch.event import service as event_service -from dispatch.incident import flows as incident_flows -from dispatch.incident import service as incident_service -from dispatch.incident.enums import IncidentStatus -from dispatch.incident.models import IncidentUpdate, IncidentRead, IncidentCreate -from dispatch.individual.models import IndividualContactRead -from dispatch.participant import service as participant_service -from dispatch.participant.models import ParticipantUpdate -from dispatch.plugin import service as plugin_service -from dispatch.tag import service as tag_service - -from dispatch.plugins.dispatch_slack.decorators import slack_background_task -from dispatch.plugins.dispatch_slack.messaging import create_incident_reported_confirmation_message -from dispatch.plugins.dispatch_slack.service import ( - send_ephemeral_message, - open_modal_with_user, - update_modal_with_user, - get_user_profile_by_email, -) -from dispatch.plugins.dispatch_slack.modals.common import parse_submitted_form -from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration - -from .enums import ( - IncidentBlockId, - UpdateParticipantBlockId, - UpdateNotificationsGroupBlockId, - AddTimelineEventBlockId, -) -from .views import ( - update_incident, - report_incident, - update_participant, - update_notifications_group, - add_timeline_event, -) - - -# report incident -@slack_background_task -def create_report_incident_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a modal for reporting an incident.""" - trigger_id = command.get("trigger_id") - modal_create_template = report_incident(channel_id=channel_id, db_session=db_session) - open_modal_with_user(client=slack_client, trigger_id=trigger_id, modal=modal_create_template) - - -@slack_background_task -def update_report_incident_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict = None, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for reporting an incident.""" - trigger_id = action["trigger_id"] - - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - # we must pass existing values if they exist - modal_update_template = report_incident( - db_session=db_session, - channel_id=channel_id, - title=parsed_form_data[IncidentBlockId.title], - description=parsed_form_data[IncidentBlockId.description], - project_name=parsed_form_data[IncidentBlockId.project]["value"], - ) - - update_modal_with_user( - client=slack_client, - trigger_id=trigger_id, - view_id=action["view"]["id"], - modal=modal_update_template, - ) - - -@slack_background_task -def report_incident_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session: SessionLocal = None, - slack_client=None, -): - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - # Send a confirmation to the user - blocks = create_incident_reported_confirmation_message( - title=parsed_form_data[IncidentBlockId.title], - description=parsed_form_data[IncidentBlockId.description], - incident_type=parsed_form_data[IncidentBlockId.type]["value"], - incident_priority=parsed_form_data[IncidentBlockId.priority]["value"], - ) - - send_ephemeral_message( - client=slack_client, - conversation_id=channel_id, - user_id=user_id, - text="", - blocks=blocks, - ) - - tags = [] - for t in parsed_form_data.get(IncidentBlockId.tags, []): - # we have to fetch as only the IDs are embedded in slack - tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) - tags.append(tag) - - project = {"name": parsed_form_data[IncidentBlockId.project]["value"]} - incident_in = IncidentCreate( - title=parsed_form_data[IncidentBlockId.title], - description=parsed_form_data[IncidentBlockId.description], - incident_type={"name": parsed_form_data[IncidentBlockId.type]["value"]}, - incident_priority={"name": parsed_form_data[IncidentBlockId.priority]["value"]}, - project=project, - tags=tags, - ) - - if not incident_in.reporter: - incident_in.reporter = ParticipantUpdate(individual=IndividualContactRead(email=user_email)) - - # Create the incident - incident = incident_service.create(db_session=db_session, incident_in=incident_in) - - incident_flows.incident_create_flow( - incident_id=incident.id, - db_session=db_session, - organization_slug=incident.project.organization.slug, - ) - - -# update incident -@slack_background_task -def create_update_incident_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - config: SlackConversationConfiguration = None, - command: dict = None, - db_session=None, - slack_client=None, -): - """Creates a dialog for updating incident information.""" - modal = update_incident(db_session=db_session, channel_id=channel_id, incident_id=incident_id) - open_modal_with_user(client=slack_client, trigger_id=command["trigger_id"], modal=modal) - - -@slack_background_task -def update_incident_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Massages slack dialog data into something that Dispatch can use.""" - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - tags = [] - for t in parsed_form_data.get(IncidentBlockId.tags, []): - # we have to fetch as only the IDs are embedded in slack - tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) - tags.append(tag) - - incident_in = IncidentUpdate( - title=parsed_form_data[IncidentBlockId.title], - description=parsed_form_data[IncidentBlockId.description], - resolution=parsed_form_data[IncidentBlockId.resolution], - incident_type={"name": parsed_form_data[IncidentBlockId.type]["value"]}, - incident_severity={"name": parsed_form_data[IncidentBlockId.severity]["value"]}, - incident_priority={"name": parsed_form_data[IncidentBlockId.priority]["value"]}, - status=parsed_form_data[IncidentBlockId.status]["value"], - tags=tags, - ) - - previous_incident = IncidentRead.from_orm(incident) - - # we currently don't allow users to update the incident's visibility, - # costs, terms, or duplicates via Slack, so we copy them over - incident_in.visibility = incident.visibility - incident_in.incident_costs = incident.incident_costs - incident_in.terms = incident.terms - incident_in.duplicates = incident.duplicates - - updated_incident = incident_service.update( - db_session=db_session, incident=incident, incident_in=incident_in - ) - - commander_email = updated_incident.commander.individual.email - reporter_email = updated_incident.reporter.individual.email - incident_flows.incident_update_flow( - user_email, - commander_email, - reporter_email, - incident_id, - previous_incident, - db_session=db_session, - ) - - if updated_incident.status != IncidentStatus.closed: - send_ephemeral_message( - slack_client, channel_id, user_id, "You have sucessfully updated the incident." - ) - - -# update participant -@slack_background_task -def create_update_participant_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - command: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for updating a participant.""" - trigger_id = command["trigger_id"] - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - modal_create_template = update_participant(incident=incident) - open_modal_with_user(client=slack_client, trigger_id=trigger_id, modal=modal_create_template) - - -@slack_background_task -def update_update_participant_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Pushes an updated view to the update participant modal.""" - trigger_id = action["trigger_id"] - - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - participant_id = parsed_form_data[UpdateParticipantBlockId.participant]["value"] - - selected_participant = participant_service.get( - db_session=db_session, participant_id=participant_id - ) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - modal_update_template = update_participant(incident=incident, participant=selected_participant) - - update_modal_with_user( - client=slack_client, - trigger_id=trigger_id, - view_id=action["view"]["id"], - modal=modal_update_template, - ) - - -@slack_background_task -def update_participant_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Saves form data.""" - submitted_form = action.get("view") - - parsed_form_data = parse_submitted_form(submitted_form) - - added_reason = parsed_form_data.get(UpdateParticipantBlockId.reason_added) - participant_id = int(parsed_form_data.get(UpdateParticipantBlockId.participant)["value"]) - selected_participant = participant_service.get( - db_session=db_session, participant_id=participant_id - ) - participant_service.update( - db_session=db_session, - participant=selected_participant, - participant_in=ParticipantUpdate(added_reason=added_reason), - ) - - send_ephemeral_message( - client=slack_client, - conversation_id=channel_id, - user_id=user_id, - text="You have successfully updated the participant.", - ) - - -# update notification group -@slack_background_task -def create_update_notifications_group_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - command: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for editing members of the notifications group.""" - trigger_id = command["trigger_id"] - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - modal_create_template = update_notifications_group(incident=incident, db_session=db_session) - - open_modal_with_user(client=slack_client, trigger_id=trigger_id, modal=modal_create_template) - - -@slack_background_task -def update_notifications_group_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Updates notifications group based on submitted form data.""" - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - current_members = ( - submitted_form["blocks"][1]["element"]["initial_value"].replace(" ", "").split(",") - ) - updated_members = ( - parsed_form_data.get(UpdateNotificationsGroupBlockId.update_members) - .replace(" ", "") - .split(",") - ) - - members_added = list(set(updated_members) - set(current_members)) - members_removed = list(set(current_members) - set(updated_members)) - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - group_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" - ) - - group_plugin.instance.add(incident.notifications_group.email, members_added) - group_plugin.instance.remove(incident.notifications_group.email, members_removed) - - send_ephemeral_message( - client=slack_client, - conversation_id=channel_id, - user_id=user_id, - text="You have successfully updated the notifications group.", - ) - - -# add event -@slack_background_task -def create_add_timeline_event_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - command: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for adding events to the incident timeline.""" - trigger_id = command["trigger_id"] - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - modal_create_template = add_timeline_event(incident=incident) - open_modal_with_user(client=slack_client, trigger_id=trigger_id, modal=modal_create_template) - - -@slack_background_task -def add_timeline_event_from_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Adds event to incident timeline based on submitted form data.""" - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - event_date = parsed_form_data.get(AddTimelineEventBlockId.date) - event_hour = parsed_form_data.get(AddTimelineEventBlockId.hour)["value"] - event_minute = parsed_form_data.get(AddTimelineEventBlockId.minute)["value"] - event_timezone_selection = parsed_form_data.get(AddTimelineEventBlockId.timezone)["value"] - event_description = parsed_form_data.get(AddTimelineEventBlockId.description) - - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident_id, email=user_email - ) - - event_timezone = event_timezone_selection - if event_timezone_selection == "profile": - participant_profile = get_user_profile_by_email(slack_client, user_email) - if participant_profile.get("tz"): - event_timezone = participant_profile.get("tz") - - event_dt = datetime.fromisoformat(f"{event_date}T{event_hour}:{event_minute}") - event_dt_utc = pytz.timezone(event_timezone).localize(event_dt).astimezone(pytz.utc) - - event_service.log_incident_event( - db_session=db_session, - source="Slack Plugin - Conversation Management", - started_at=event_dt_utc, - description=f'"{event_description}," said {participant.individual.name}', - incident_id=incident_id, - individual_id=participant.individual.id, - ) - - send_ephemeral_message( - client=slack_client, - conversation_id=channel_id, - user_id=user_id, - text="Event sucessfully added to timeline.", - ) diff --git a/src/dispatch/plugins/dispatch_slack/modals/incident/views.py b/src/dispatch/plugins/dispatch_slack/modals/incident/views.py deleted file mode 100644 index ab090ca214b6..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/incident/views.py +++ /dev/null @@ -1,331 +0,0 @@ -import json - -from dispatch.database.core import SessionLocal -from dispatch.incident import service as incident_service -from dispatch.incident.models import Incident -from dispatch.participant.models import Participant -from dispatch.plugin import service as plugin_service -from dispatch.project import service as project_service - -from .enums import ( - AddTimelineEventBlockId, - AddTimelineEventCallbackId, - ReportIncidentCallbackId, - UpdateIncidentCallbackId, - UpdateNotificationsGroupBlockId, - UpdateNotificationsGroupCallbackId, - UpdateParticipantBlockId, - UpdateParticipantCallbackId, -) - -from .fields import ( - description_input_block, - incident_priority_select_block, - incident_severity_select_block, - incident_type_select_block, - option_from_template, - participants_select_block, - project_select_block, - resolution_input_block, - status_select_block, - tag_multi_select_block, - title_input_block, -) - - -def update_incident(db_session: SessionLocal, channel_id: str, incident_id: int = None): - """Builds all blocks required for the update incident modal.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Update Incident"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Use this form to update the incident details.", - } - ], - }, - title_input_block(initial_value=incident.title), - description_input_block(initial_value=incident.description), - resolution_input_block(initial_value=incident.resolution), - status_select_block(initial_option=incident.status), - incident_type_select_block( - db_session=db_session, - initial_option=incident.incident_type, - project_id=incident.project.id, - ), - incident_severity_select_block( - db_session=db_session, - initial_option=incident.incident_severity, - project_id=incident.project.id, - ), - incident_priority_select_block( - db_session=db_session, - initial_option=incident.incident_priority, - project_id=incident.project.id, - ), - tag_multi_select_block(initial_options=incident.tags), - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": UpdateIncidentCallbackId.submit_form, - "private_metadata": json.dumps({"channel_id": str(channel_id), "incident_id": incident.id}), - } - - return modal_template - - -def report_incident( - db_session: SessionLocal, - channel_id: str, - project_name: str = None, - title: str = None, - description: str = None, -): - """Builds all blocks required for the reporting incident modal.""" - project = project_service.get_by_name(db_session=db_session, name=project_name) - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Incident Report"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "If you suspect an incident and need help, " - "please fill out this form to the best of your abilities.", - } - ], - }, - title_input_block(initial_value=title), - description_input_block(initial_value=description), - project_select_block(db_session=db_session, initial_option=project), - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": ReportIncidentCallbackId.update_view, - "private_metadata": json.dumps({"channel_id": str(channel_id)}), - } - - # switch from update to submit when we have a project - if project: - modal_template["callback_id"] = ReportIncidentCallbackId.submit_form - modal_template["blocks"] += [ - incident_type_select_block(db_session=db_session, project_id=project.id), - incident_priority_select_block(db_session=db_session, project_id=project.id), - tag_multi_select_block(), - ] - - return modal_template - - -def update_participant(incident: Incident, participant: Participant = None): - """Builds all blocks required for updating the participant modal.""" - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Update Participant"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Use this form to update the reason why the participant was added to the incident.", - } - ], - }, - participants_select_block(incident=incident, initial_option=participant), - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": UpdateParticipantCallbackId.update_view, - "private_metadata": json.dumps( - {"incident_id": str(incident.id), "channel_id": str(incident.conversation.channel_id)} - ), - } - - # we need to show the reason if we're updating - if participant: - modal_template["blocks"].append( - { - "block_id": UpdateParticipantBlockId.reason_added, - "type": "input", - "element": { - "type": "plain_text_input", - "multiline": True, - "initial_value": participant.added_reason or "", - "action_id": UpdateParticipantBlockId.reason_added, - }, - "label": {"type": "plain_text", "text": "Reason Added"}, - } - ) - - modal_template["callback_id"] = UpdateParticipantCallbackId.submit_form - - return modal_template - - -def update_notifications_group(incident: Incident, db_session: SessionLocal): - """Builds all blocks required to update the membership of the notifications group.""" - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Update Group Membership"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "plain_text", - "text": "Use this form to update the membership of the notifications group.", - } - ], - }, - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": UpdateNotificationsGroupCallbackId.submit_form, - "private_metadata": json.dumps( - {"incident_id": str(incident.id), "channel_id": incident.conversation.channel_id} - ), - } - - group_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" - ) - members = group_plugin.instance.list(incident.notifications_group.email) - - members_block = { - "type": "input", - "block_id": UpdateNotificationsGroupBlockId.update_members, - "label": {"type": "plain_text", "text": "Members"}, - "element": { - "type": "plain_text_input", - "action_id": UpdateNotificationsGroupBlockId.update_members, - "multiline": True, - "initial_value": (", ").join(members), - }, - } - modal_template["blocks"].append(members_block) - - modal_template["blocks"].append( - { - "type": "context", - "elements": [{"type": "plain_text", "text": "Separate email addresses with commas."}], - }, - ) - - return modal_template - - -def add_timeline_event(incident: Incident): - """Builds all blocks required to add an event to the incident timeline.""" - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Add Timeline Event"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "plain_text", - "text": "Use this form to add an event to the incident timeline.", - } - ], - }, - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "callback_id": AddTimelineEventCallbackId.submit_form, - "private_metadata": json.dumps( - {"incident_id": str(incident.id), "channel_id": str(incident.conversation.channel_id)} - ), - } - - date_picker_block = { - "type": "input", - "block_id": AddTimelineEventBlockId.date, - "label": {"type": "plain_text", "text": "Date"}, - "element": {"type": "datepicker"}, - "optional": False, - } - modal_template["blocks"].append(date_picker_block) - - hour_picker_options = [] - for h in range(0, 24): - h = str(h).zfill(2) - hour_picker_options.append(option_from_template(text=f"{h}:00", value=h)) - - hour_picker_block = { - "type": "input", - "block_id": AddTimelineEventBlockId.hour, - "label": {"type": "plain_text", "text": "Hour"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select an hour"}, - "options": hour_picker_options, - }, - "optional": False, - } - modal_template["blocks"].append(hour_picker_block) - - minute_picker_options = [] - for m in range(0, 60): - minute_picker_options.append(option_from_template(text=m, value=str(m).zfill(2))) - - minute_picker_block = { - "type": "input", - "block_id": AddTimelineEventBlockId.minute, - "label": {"type": "plain_text", "text": "Minute"}, - "element": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a minute"}, - "options": minute_picker_options, - }, - "optional": False, - } - modal_template["blocks"].append(minute_picker_block) - - timezone_block = { - "type": "input", - "block_id": AddTimelineEventBlockId.timezone, - "label": {"type": "plain_text", "text": "Time Zone"}, - "element": { - "type": "radio_buttons", - "initial_option": { - "value": "profile", - "text": {"type": "plain_text", "text": "Local time from Slack profile"}, - }, - "options": [ - { - "text": {"type": "plain_text", "text": "Local time from Slack profile"}, - "value": "profile", - }, - { - "text": {"type": "plain_text", "text": "Coordinated Universal Time (UTC)"}, - "value": "UTC", - }, - ], - }, - } - modal_template["blocks"].append(timezone_block) - - description_block = { - "type": "input", - "block_id": AddTimelineEventBlockId.description, - "label": {"type": "plain_text", "text": "Description"}, - "element": { - "type": "plain_text_input", - "action_id": AddTimelineEventBlockId.description, - "placeholder": {"type": "plain_text", "text": "A description of the event"}, - }, - "optional": False, - } - modal_template["blocks"].append(description_block) - - return modal_template diff --git a/src/dispatch/plugins/dispatch_slack/modals/workflow/__init__.py b/src/dispatch/plugins/dispatch_slack/modals/workflow/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/dispatch/plugins/dispatch_slack/modals/workflow/handlers.py b/src/dispatch/plugins/dispatch_slack/modals/workflow/handlers.py deleted file mode 100644 index d3f157e660aa..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/workflow/handlers.py +++ /dev/null @@ -1,211 +0,0 @@ -from dispatch.config import DISPATCH_UI_URL -from dispatch.incident import service as incident_service -from dispatch.messaging.strings import INCIDENT_WORKFLOW_CREATED_NOTIFICATION -from dispatch.participant import service as participant_service -from dispatch.workflow import service as workflow_service -from dispatch.workflow.flows import send_workflow_notification -from dispatch.workflow.models import WorkflowInstanceCreate - -from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration -from dispatch.plugins.dispatch_slack.decorators import slack_background_task -from dispatch.plugins.dispatch_slack.modals.common import parse_submitted_form -from dispatch.plugins.dispatch_slack.service import ( - get_user_email, - open_modal_with_user, - send_ephemeral_message, - update_modal_with_user, -) - -from .views import run_workflow_view, RunWorkflowBlockId, RunWorkflowCallbackId - - -@slack_background_task -def create_run_workflow_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - command: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Creates a modal for running a workflow.""" - trigger_id = command.get("trigger_id") - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - workflows = workflow_service.get_enabled(db_session=db_session) - workflows = [x for x in workflows if x.plugin_instance.enabled] - - if workflows: - modal_create_template = run_workflow_view(incident=incident, workflows=workflows) - - open_modal_with_user( - client=slack_client, trigger_id=trigger_id, modal=modal_create_template - ) - else: - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "No workflows are enabled or workflows plugin is not enabled. You can enable one in the Dispatch UI at /workflows.", - }, - } - ] - send_ephemeral_message( - slack_client, - command["channel_id"], - command["user_id"], - "No workflows enabled.", - blocks=blocks, - ) - - -@slack_background_task -def update_workflow_modal( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Pushes an updated view to the run workflow modal.""" - trigger_id = action["trigger_id"] - incident_id = action["view"]["private_metadata"]["incident_id"] - workflow_id = action["actions"][0]["selected_option"]["value"] - - selected_workflow = workflow_service.get(db_session=db_session, workflow_id=workflow_id) - workflows = workflow_service.get_enabled(db_session=db_session) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - modal_template = run_workflow_view( - incident=incident, workflows=workflows, selected_workflow=selected_workflow - ) - - modal_template["blocks"].append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Description* \n {selected_workflow.description}"}, - }, - ) - - modal_template["blocks"].append( - { - "block_id": RunWorkflowBlockId.run_reason, - "type": "input", - "element": { - "type": "plain_text_input", - "multiline": True, - "action_id": RunWorkflowBlockId.run_reason, - }, - "label": {"type": "plain_text", "text": "Run Reason"}, - }, - ) - - modal_template["blocks"].append( - {"type": "section", "text": {"type": "mrkdwn", "text": "*Parameters*"}} - ) - - if selected_workflow.parameters: - for p in selected_workflow.parameters: - modal_template["blocks"].append( - { - "block_id": f"{RunWorkflowBlockId.param}-{p['key']}", - "type": "input", - "element": { - "type": "plain_text_input", - "placeholder": {"type": "plain_text", "text": "Value"}, - }, - "label": {"type": "plain_text", "text": p["key"]}, - } - ) - - else: - modal_template["blocks"].append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": "This workflow has no parameters."}, - } - ) - - modal_template["callback_id"] = RunWorkflowCallbackId.submit_form - - update_modal_with_user( - client=slack_client, - trigger_id=trigger_id, - view_id=action["view"]["id"], - modal=modal_template, - ) - - -@slack_background_task -def run_workflow_submitted_form( - user_id: str, - user_email: str, - channel_id: str, - incident_id: int, - action: dict, - config: SlackConversationConfiguration = None, - db_session=None, - slack_client=None, -): - """Runs an external flow.""" - submitted_form = action.get("view") - parsed_form_data = parse_submitted_form(submitted_form) - - params = {} - named_params = [] - for i in parsed_form_data.keys(): - if i.startswith(RunWorkflowBlockId.param): - key = i.split("-")[1] - value = parsed_form_data[i] - params.update({key: value}) - named_params.append({"key": key, "value": value}) - - workflow_id = parsed_form_data.get(RunWorkflowBlockId.workflow_select)["value"] - run_reason = parsed_form_data.get(RunWorkflowBlockId.run_reason) - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - workflow = workflow_service.get(db_session=db_session, workflow_id=workflow_id) - - creator_email = get_user_email(slack_client, action["user"]["id"]) - creator = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident.id, email=creator_email - ) - - instance = workflow_service.create_instance( - db_session=db_session, - instance_in=WorkflowInstanceCreate( - workflow=workflow, - incident=incident, - creator=creator, - run_reason=run_reason, - parameters=named_params, - ), - ) - - for p in instance.parameters: - if p["value"]: - params.update({p["key"]: p["value"]}) - - params.update( - { - "externalRef": f"{DISPATCH_UI_URL}/{instance.incident.project.organization.name}/incidents/{instance.incident.name}?project={instance.incident.project.name}", - "workflowInstanceId": instance.id, - } - ) - - workflow.plugin_instance.instance.run(workflow.resource_id, params) - - send_workflow_notification( - incident.project.id, - incident.conversation.channel_id, - INCIDENT_WORKFLOW_CREATED_NOTIFICATION, - db_session, - instance_creator_name=instance.creator.individual.name, - workflow_name=instance.workflow.name, - workflow_description=instance.workflow.description, - ) diff --git a/src/dispatch/plugins/dispatch_slack/modals/workflow/views.py b/src/dispatch/plugins/dispatch_slack/modals/workflow/views.py deleted file mode 100644 index 989098b185ae..000000000000 --- a/src/dispatch/plugins/dispatch_slack/modals/workflow/views.py +++ /dev/null @@ -1,98 +0,0 @@ -import json -from typing import List - -from dispatch.enums import DispatchEnum -from dispatch.incident.models import Incident -from dispatch.workflow.models import Workflow - - -class RunWorkflowBlockId(DispatchEnum): - workflow_select = "run_workflow_select" - run_reason = "run_workflow_run_reason" - param = "run_workflow_param" - - -class RunWorkflowCallbackId(DispatchEnum): - submit_form = "run_workflow_submit_form" - update_view = "run_workflow_update_view" - - -def run_workflow_view( - incident: Incident, workflows: List[Workflow], selected_workflow: Workflow = None -): - """Builds all blocks required to run a workflow.""" - modal_template = { - "type": "modal", - "title": {"type": "plain_text", "text": "Run workflow"}, - "blocks": [ - { - "type": "context", - "elements": [ - { - "type": "plain_text", - "text": "Use this form to run a workflow.", - } - ], - }, - ], - "close": {"type": "plain_text", "text": "Cancel"}, - "submit": {"type": "plain_text", "text": "Run"}, - "callback_id": RunWorkflowCallbackId.update_view, - "private_metadata": json.dumps( - {"incident_id": str(incident.id), "channel_id": incident.conversation.channel_id} - ), - } - - selected_option = None - workflow_options = [] - for w in workflows: - current_option = { - "text": { - "type": "plain_text", - "text": w.name, - }, - "value": str(w.id), - } - - workflow_options.append(current_option) - - if selected_workflow: - if w.id == selected_workflow.id: - selected_option = current_option - - if selected_workflow: - select_block = { - "block_id": RunWorkflowBlockId.workflow_select, - "type": "input", - "element": { - "type": "static_select", - "placeholder": { - "type": "plain_text", - "text": "Select Workflow", - }, - "initial_option": selected_option, - "options": workflow_options, - "action_id": RunWorkflowBlockId.workflow_select, - }, - "label": {"type": "plain_text", "text": "Workflow"}, - } - else: - select_block = { - "block_id": RunWorkflowBlockId.workflow_select, - "type": "actions", - "elements": [ - { - "type": "static_select", - "placeholder": { - "type": "plain_text", - "text": "Select Workflow", - }, - "options": workflow_options, - "action_id": RunWorkflowBlockId.workflow_select, - } - ], - } - - modal_template["blocks"].append(select_block) - - return modal_template diff --git a/src/dispatch/plugins/dispatch_slack/models.py b/src/dispatch/plugins/dispatch_slack/models.py index eb2d609d9435..3126d20b757d 100644 --- a/src/dispatch/plugins/dispatch_slack/models.py +++ b/src/dispatch/plugins/dispatch_slack/models.py @@ -1,16 +1,20 @@ +from typing import Optional from pydantic import BaseModel -class ButtonValue(BaseModel): +class SubjectMetadata(BaseModel): + id: Optional[str] + type: Optional[str] organization_slug: str = "default" - incident_id: str - action_type: str + project_id: Optional[str] + channel_id: Optional[str] -class TaskButton(ButtonValue): + +class TaskMetadata(SubjectMetadata): resource_id: str -class MonitorButton(ButtonValue): +class MonitorMetadata(SubjectMetadata): weblink: str plugin_instance_id: int diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 571db5116da5..637baccfd9ea 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -5,24 +5,26 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ -from joblib import Memory -from typing import List, Optional import logging import os import re +from typing import List, Optional + +from blockkit import Message +from joblib import Memory from dispatch.conversation.enums import ConversationCommands from dispatch.decorators import apply, counter, timer from dispatch.exceptions import DispatchPluginException from dispatch.plugins import dispatch_slack as slack_plugin -from dispatch.plugins.bases import ConversationPlugin, DocumentPlugin, ContactPlugin +from dispatch.plugins.bases import ContactPlugin, ConversationPlugin, DocumentPlugin from dispatch.plugins.dispatch_slack.config import ( SlackConfiguration, SlackContactConfiguration, SlackConversationConfiguration, ) -from .views import router as slack_event_router +from .bolt import router as slack_event_router from .messaging import create_message_blocks from .service import ( add_users_to_conversation, @@ -42,10 +44,10 @@ send_ephemeral_message, send_message, set_conversation_topic, + set_conversation_bookmark, unarchive_conversation, ) - logger = logging.getLogger(__name__) @@ -83,10 +85,14 @@ def send( """Sends a new message based on data and type.""" client = create_slack_client(self.configuration) if not blocks: - blocks = create_message_blocks(message_template, notification_type, items, **kwargs) + blocks = Message( + blocks=create_message_blocks(message_template, notification_type, items, **kwargs) + ).build()["blocks"] + messages = [] for c in chunks(blocks, 50): - send_message(client, conversation_id, text, c, persist) + messages.append(send_message(client, conversation_id, text, c, persist)) + return messages def send_direct( self, @@ -103,7 +109,9 @@ def send_direct( user_id = resolve_user(client, user)["id"] if not blocks: - blocks = create_message_blocks(message_template, notification_type, items, **kwargs) + blocks = Message( + blocks=create_message_blocks(message_template, notification_type, items, **kwargs) + ).build()["blocks"] return send_message(client, user_id, text, blocks) @@ -123,7 +131,9 @@ def send_ephemeral( user_id = resolve_user(client, user)["id"] if not blocks: - blocks = create_message_blocks(message_template, notification_type, items, **kwargs) + blocks = Message( + blocks=create_message_blocks(message_template, notification_type, items, **kwargs) + ).build()["blocks"] archived = conversation_archived(client, conversation_id) if not archived: @@ -158,6 +168,11 @@ def set_topic(self, conversation_id: str, topic: str): client = create_slack_client(self.configuration) return set_conversation_topic(client, conversation_id, topic) + def set_bookmark(self, conversation_id: str, weblink: str, title: str): + """Sets the conversation bookmark.""" + client = create_slack_client(self.configuration) + return set_conversation_bookmark(client, conversation_id, weblink, title) + def get_command_name(self, command: str): """Gets the command name.""" command_mappings = { diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 5c2656d94b19..1cdb37b21137 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,6 +1,9 @@ import time import logging import functools +import inspect +from pydantic import BaseModel +from pydantic.error_wrappers import ErrorWrapper, ValidationError import slack_sdk from slack_sdk.web.async_client import AsyncWebClient @@ -9,11 +12,87 @@ from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt +from dispatch.exceptions import NotFoundError +from dispatch.database.core import SessionLocal, sessionmaker, engine +from dispatch.conversation import service as conversation_service +from dispatch.organization import service as organization_service from .config import SlackConversationConfiguration log = logging.getLogger(__name__) +# we need a way to determine which organization to use for a given +# event, we use the unique channel id to determine which organization the +# event belongs to. +def get_organization_scope_from_channel_id(channel_id: str) -> SessionLocal: + """Iterate all organizations looking for a relevant channel_id.""" + db_session = SessionLocal() + organization_slugs = [o.slug for o in organization_service.get_all(db_session=db_session)] + db_session.close() + + for slug in organization_slugs: + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{slug}", + } + ) + + scoped_db_session = sessionmaker(bind=schema_engine)() + conversation = conversation_service.get_by_channel_id_ignoring_channel_type( + db_session=scoped_db_session, channel_id=channel_id + ) + if conversation: + return scoped_db_session + + scoped_db_session.close() + + +def get_organization_scope_from_slug(slug: str) -> SessionLocal: + """Iterate all organizations looking for a matching slug.""" + db_session = SessionLocal() + organization = organization_service.get_by_slug(db_session=db_session, slug=slug) + db_session.close() + + if organization: + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{slug}", + } + ) + + return sessionmaker(bind=schema_engine)() + + raise ValidationError( + [ + ErrorWrapper( + NotFoundError(msg=f"Organization slug '{slug}' not found. Check your spelling."), + loc="organization", + ) + ], + model=BaseModel, + ) + + +def get_default_organization_scope() -> str: + """Iterate all organizations looking for matching organization.""" + db_session = SessionLocal() + organization = organization_service.get_default(db_session=db_session) + db_session.close() + + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{organization.slug}", + } + ) + + return sessionmaker(bind=schema_engine)() + + +def fullname(o): + module = inspect.getmodule(o) + return f"{module.__name__}.{o.__qualname__}" + + def create_slack_client(config: SlackConversationConfiguration, run_async: bool = False): """Creates a Slack Web API client.""" if not run_async: @@ -182,6 +261,11 @@ def get_user_info_by_email(client: Any, email: str): return make_call(client, "users.lookupByEmail", email=email)["user"] +async def get_user_info_by_email_async(client: Any, email: str): + """Gets profile information about a user by email.""" + return (await make_call_async(client, "users.lookupByEmail", email=email))["user"] + + @functools.lru_cache() def get_user_profile_by_email(client: Any, email: str): """Gets extended profile information about a user by email.""" @@ -191,6 +275,14 @@ def get_user_profile_by_email(client: Any, email: str): return profile +async def get_user_profile_by_email_async(client: Any, email: str): + """Gets extended profile information about a user by email.""" + user = (await make_call(client, "users.lookupByEmail", email=email))["user"] + profile = (await make_call(client, "users.profile.get", user=user["id"]))["profile"] + profile["tz"] = user["tz"] + return profile + + def get_user_email(client: Any, user_id: str): """Gets the user's email.""" user_info = get_user_info_by_id(client, user_id) @@ -213,28 +305,9 @@ def get_user_avatar_url(client: Any, email: str): return get_user_info_by_email(client, email)["profile"]["image_512"] -# @functools.lru_cache() -async def get_conversations_by_user_id_async(client: Any, user_id: str): - """Gets the list of public and private conversations a user is a member of.""" - result = await make_call_async( - client, - "users.conversations", - user=user_id, - types="public_channel", - exclude_archived="true", - ) - public_conversations = [c["name"] for c in result["channels"]] - - result = await make_call_async( - client, - "users.conversations", - user=user_id, - types="private_channel", - exclude_archived="true", - ) - private_conversations = [c["name"] for c in result["channels"]] - - return public_conversations, private_conversations +async def get_user_avatar_url_async(client: Any, email: str): + """Gets the user's avatar url.""" + return (await get_user_info_by_email_async(client, email))["profile"]["image_512"] # note this will get slower over time, we might exclude archived to make it sane @@ -245,24 +318,23 @@ def get_conversation_by_name(client: Any, name: str): return c -async def get_conversation_name_by_id_async(client: Any, conversation_id: str): - """Fetches a conversation by id and returns its name.""" - try: - return (await make_call_async(client, "conversations.info", channel=conversation_id))[ - "channel" - ]["name"] - except slack_sdk.errors.SlackApiError as e: - if e.response["error"] == "channel_not_found": - return None - else: - raise e - - def set_conversation_topic(client: Any, conversation_id: str, topic: str): """Sets the topic of the specified conversation.""" return make_call(client, "conversations.setTopic", channel=conversation_id, topic=topic) +def set_conversation_bookmark(client: Any, conversation_id: str, weblink: str, title: str): + """Sets a bookmark for the specified conversation.""" + return make_call( + client, + "bookmarks.add", + channel_id=conversation_id, + title=title, + type="link", + link=weblink, + ) + + def create_conversation(client: Any, name: str, is_private: bool = False): """Make a new Slack conversation.""" response = make_call( @@ -394,22 +466,6 @@ def message_filter(message): return message -def is_user(config: SlackConversationConfiguration, user_id: str): +def is_user(config: SlackConversationConfiguration, user_id: str) -> bool: """Returns true if it's a regular user, false if Dispatch or Slackbot bot'.""" return user_id != config.app_user_slug and user_id != "USLACKBOT" - - -def open_dialog_with_user(client: Any, trigger_id: str, dialog: dict): - """Opens a dialog with a user.""" - return make_call(client, "dialog.open", trigger_id=trigger_id, dialog=dialog) - - -def open_modal_with_user(client: Any, trigger_id: str, modal: dict): - """Opens a modal with a user.""" - # the argument should be view in the make call, since slack api expects view - return make_call(client, "views.open", trigger_id=trigger_id, view=modal) - - -def update_modal_with_user(client: Any, trigger_id: str, view_id: str, modal: dict): - """Updates a modal with a user.""" - return make_call(client, "views.update", trigger_id=trigger_id, view_id=view_id, view=modal) diff --git a/src/dispatch/plugins/dispatch_slack/socket_mode.py b/src/dispatch/plugins/dispatch_slack/socket_mode.py deleted file mode 100644 index 52c96faf31d5..000000000000 --- a/src/dispatch/plugins/dispatch_slack/socket_mode.py +++ /dev/null @@ -1,78 +0,0 @@ -import logging - -import asyncio -from fastapi import BackgroundTasks - -from slack_sdk.web.async_client import AsyncWebClient - -from dispatch.plugins.dispatch_slack.menus import handle_slack_menu - -from .actions import handle_engage_oncall_action, handle_slack_action -from .commands import handle_slack_command -from .events import handle_slack_event, EventEnvelope - -log = logging.getLogger(__name__) - - -async def run_websocket_process(config): - from slack_sdk.socket_mode.aiohttp import SocketModeClient - from slack_sdk.socket_mode.response import SocketModeResponse - from slack_sdk.socket_mode.request import SocketModeRequest - - # Initialize SocketModeClient with an app-level token + WebClient - client = SocketModeClient( - # This app-level token will be used only for establishing a connection - app_token=config.socket_mode_app_token.get_secret_value(), # xapp-A111-222-xyz - # You will be using this WebClient for performing Web API calls in listeners - web_client=AsyncWebClient( - token=config.api_bot_token.get_secret_value() - ), # xoxb-111-222-xyz - ) - - async def process(client: SocketModeClient, req: SocketModeRequest): - background_tasks = BackgroundTasks() - - if req.type == "events_api": - response = await handle_slack_event( - config=config, - client=client.web_client, - event=EventEnvelope(**req.payload), - background_tasks=background_tasks, - ) - - if req.type == "slash_commands": - response = await handle_slack_command( - config=config, - client=client.web_client, - request=req.payload, - background_tasks=background_tasks, - ) - - if req.type == "interactive": - if req.payload["type"] == "block_suggestion": - response = await handle_slack_menu( - config=config, - client=client.web_client, - request=req.payload, - ) - - else: - response = await handle_slack_action( - config=config, - client=client.web_client, - request=req.payload, - background_tasks=background_tasks, - ) - - response = SocketModeResponse(envelope_id=req.envelope_id, payload=response) - await client.send_socket_mode_response(response) - - # run the background tasks - await background_tasks() - - # Add a new listener to receive messages from Slack - # You can add more listeners like this - client.socket_mode_request_listeners.append(process) - # Establish a WebSocket connection to the Socket Mode servers - await client.connect() - await asyncio.sleep(float("inf")) diff --git a/src/dispatch/plugins/dispatch_slack/tests/__init__.py b/src/dispatch/plugins/dispatch_slack/tests/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/dispatch/plugins/dispatch_slack/views.py b/src/dispatch/plugins/dispatch_slack/views.py deleted file mode 100644 index 3135b5b5f3f2..000000000000 --- a/src/dispatch/plugins/dispatch_slack/views.py +++ /dev/null @@ -1,253 +0,0 @@ -import hashlib -import hmac -import json -import logging -import platform -import sys - -from time import time - -from fastapi import APIRouter, BackgroundTasks, Header, HTTPException - -from sqlalchemy import true - -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from dispatch.plugin.models import PluginInstance, Plugin - -from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from dispatch.plugins.dispatch_slack.decorators import get_organization_scope_from_slug - -from . import __version__ -from .actions import handle_slack_action -from .commands import handle_slack_command -from .events import handle_slack_event, EventEnvelope -from .menus import handle_slack_menu - - -router = APIRouter() - -log = logging.getLogger(__name__) - - -class SlackEventAppException(Exception): - pass - - -def create_ua_string(): - client_name = __name__.split(".")[0] - client_version = __version__ # Version is returned from _version.py - - # Collect the package info, Python version and OS version. - package_info = { - "client": "{0}/{1}".format(client_name, client_version), - "python": "Python/{v.major}.{v.minor}.{v.micro}".format(v=sys.version_info), - "system": "{0}/{1}".format(platform.system(), platform.release()), - } - - # Concatenate and format the user-agent string to be passed into request headers - ua_string = [] - for _, val in package_info.items(): - ua_string.append(val) - - return " ".join(ua_string) - - -def verify_signature(organization: str, request_data: str, timestamp: int, signature: str): - """Verifies the request signature using the app's signing secret.""" - session = get_organization_scope_from_slug(organization) - plugin_instances = ( - session.query(PluginInstance) - .join(Plugin) - .filter(PluginInstance.enabled == true(), Plugin.slug == "slack-conversation") - .all() - ) - - for p in plugin_instances: - secret = p.instance.configuration.signing_secret.get_secret_value() - req = f"v0:{timestamp}:{request_data}".encode("utf-8") - slack_signing_secret = bytes(secret, "utf-8") - h = hmac.new(slack_signing_secret, req, hashlib.sha256).hexdigest() - result = hmac.compare_digest(f"v0={h}", signature) - if result: - session.close() - return p.instance.configuration - session.close() - raise HTTPException(status_code=403, detail=[{"msg": "Invalid request signature"}]) - - -def verify_timestamp(timestamp: int): - """Verifies that the timestamp does not differ from local time by more than five minutes.""" - if abs(time() - timestamp) > 60 * 5: - raise HTTPException(status_code=403, detail=[{"msg": "Invalid request timestamp"}]) - - -@router.post( - "/slack/event", -) -async def handle_event( - event: EventEnvelope, - request: Request, - response: Response, - organization: str, - background_tasks: BackgroundTasks, - x_slack_request_timestamp: int = Header(...), - x_slack_signature: str = Header(...), -): - """Handle all incoming Slack events.""" - raw_request_body = bytes.decode(await request.body()) - - # We verify the timestamp - verify_timestamp(x_slack_request_timestamp) - - # We verify the signature - current_configuration = verify_signature( - organization, raw_request_body, x_slack_request_timestamp, x_slack_signature - ) - - # We add the user-agent string to the response headers - response.headers["X-Slack-Powered-By"] = create_ua_string() - - # Echo the URL verification challenge code back to Slack - if event.challenge: - return JSONResponse(content={"challenge": event.challenge}) - - slack_async_client = dispatch_slack_service.create_slack_client( - config=current_configuration, run_async=True - ) - - body = await handle_slack_event( - config=current_configuration, - client=slack_async_client, - event=event, - background_tasks=background_tasks, - ) - - return JSONResponse(content=body) - - -@router.post( - "/slack/command", -) -async def handle_command( - request: Request, - response: Response, - organization: str, - background_tasks: BackgroundTasks, - x_slack_request_timestamp: int = Header(...), - x_slack_signature: str = Header(...), -): - """Handle all incoming Slack commands.""" - raw_request_body = bytes.decode(await request.body()) - request_body_form = await request.form() - request = request_body_form._dict - - # We verify the timestamp - verify_timestamp(x_slack_request_timestamp) - - # We verify the signature - current_configuration = verify_signature( - organization, raw_request_body, x_slack_request_timestamp, x_slack_signature - ) - - # We add the user-agent string to the response headers - response.headers["X-Slack-Powered-By"] = create_ua_string() - - slack_async_client = dispatch_slack_service.create_slack_client( - config=current_configuration, run_async=True - ) - - body = await handle_slack_command( - config=current_configuration, - client=slack_async_client, - request=request, - background_tasks=background_tasks, - ) - - return JSONResponse(content=body) - - -@router.post( - "/slack/action", -) -async def handle_action( - request: Request, - response: Response, - organization: str, - background_tasks: BackgroundTasks, - x_slack_request_timestamp: int = Header(...), - x_slack_signature: str = Header(...), -): - """Handle all incoming Slack actions.""" - raw_request_body = bytes.decode(await request.body()) - request_body_form = await request.form() - try: - request = json.loads(request_body_form.get("payload")) - except Exception: - raise HTTPException(status_code=400, detail=[{"msg": "Bad Request"}]) - - # We verify the timestamp - verify_timestamp(x_slack_request_timestamp) - - current_configuration = verify_signature( - organization, raw_request_body, x_slack_request_timestamp, x_slack_signature - ) - - # We add the user-agent string to the response headers - response.headers["X-Slack-Powered-By"] = create_ua_string() - - # We create an async Slack client - slack_async_client = dispatch_slack_service.create_slack_client( - config=current_configuration, run_async=True - ) - - body = await handle_slack_action( - config=current_configuration, - client=slack_async_client, - request=request, - background_tasks=background_tasks, - ) - return JSONResponse(content=body) - - -@router.post( - "/slack/menu", -) -async def handle_menu( - request: Request, - response: Response, - organization: str, - x_slack_request_timestamp: int = Header(...), - x_slack_signature: str = Header(...), -): - """Handle all incoming Slack actions.""" - raw_request_body = bytes.decode(await request.body()) - request_body_form = await request.form() - try: - request = json.loads(request_body_form.get("payload")) - except Exception: - raise HTTPException(status_code=400, detail=[{"msg": "Bad Request"}]) - - # We verify the timestamp - verify_timestamp(x_slack_request_timestamp) - - # We verify the signature - current_configuration = verify_signature( - organization, raw_request_body, x_slack_request_timestamp, x_slack_signature - ) - - # We add the user-agent string to the response headers - response.headers["X-Slack-Powered-By"] = create_ua_string() - - # We create an async Slack client - slack_async_client = dispatch_slack_service.create_slack_client( - config=current_configuration, run_async=True - ) - - body = await handle_slack_menu( - config=current_configuration, - client=slack_async_client, - request=request, - organization=organization, - ) - return JSONResponse(content=body) diff --git a/src/dispatch/plugins/dispatch_slack/workflow.py b/src/dispatch/plugins/dispatch_slack/workflow.py new file mode 100644 index 000000000000..dfcb5976b873 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/workflow.py @@ -0,0 +1,297 @@ +from blockkit import Context, Input, Section, MarkdownText, Modal, PlainTextInput, Message +from slack_bolt.async_app import AsyncAck, AsyncBoltContext, AsyncRespond +from slack_sdk.web.async_client import AsyncWebClient +from sqlalchemy.orm import Session + +from dispatch.auth.models import DispatchUser +from dispatch.config import DISPATCH_UI_URL +from dispatch.database.core import SessionLocal +from dispatch.enums import DispatchEnum +from dispatch.incident import service as incident_service +from dispatch.messaging.strings import INCIDENT_WORKFLOW_CREATED_NOTIFICATION +from dispatch.participant import service as participant_service +from dispatch.plugins.dispatch_slack.bolt import app +from dispatch.plugins.dispatch_slack.middleware import ( + action_context_middleware, + command_context_middleware, + modal_submit_middleware, + db_middleware, + user_middleware, +) +from dispatch.plugins.dispatch_slack.fields import static_select_block +from dispatch.workflow import service as workflow_service +from dispatch.workflow.flows import send_workflow_notification +from dispatch.workflow.models import WorkflowInstanceCreate + + +class RunWorkflowBlockIds(DispatchEnum): + workflow_select = "run-workflow-select" + reason_input = "run-workflow-reason-input" + param_input = "run-workflow-param-input" + + +class RunWorkflowActionIds(DispatchEnum): + workflow_select = "run-workflow-workflow-select" + reason_input = "run-workflow-reason-input" + param_input = "run-workflow-param-input" + + +class RunWorkflowActions(DispatchEnum): + submit = "run-workflow-submit" + workflow_select = "run-workflow-workflow-select" + + +def configure(config): + """Maps commands/events to their functions.""" + middleware = [command_context_middleware, db_middleware] + app.command(config.slack_command_list_workflows, middleware=middleware)( + handle_workflow_list_command + ) + app.command(config.slack_command_run_workflow, middleware=middleware)( + handle_workflow_run_command + ) + + +def workflow_select( + db_session: SessionLocal, + action_id: str = RunWorkflowActionIds.workflow_select, + block_id: str = RunWorkflowBlockIds.workflow_select, + initial_option: dict = None, + label: str = "Workflow", + **kwargs, +): + workflows = workflow_service.get_enabled(db_session=db_session) + + return static_select_block( + action_id=action_id, + block_id=block_id, + initial_option=initial_option, + label=label, + options=[{"text": w.name, "value": w.id} for w in workflows], + placeholder="Select Workflow", + **kwargs, + ) + + +def reason_input( + action_id: str = RunWorkflowActionIds.reason_input, + block_id: str = RunWorkflowBlockIds.reason_input, + initial_value: str = None, + label: str = "Reason", + **kwargs, +): + return Input( + block_id=block_id, + element=PlainTextInput( + action_id=action_id, + initial_value=initial_value, + multiline=True, + placeholder="Short description why workflow was run.", + ), + label=label, + **kwargs, + ) + + +def param_input( + action_id: str = RunWorkflowActionIds.param_input, + block_id: str = RunWorkflowBlockIds.param_input, + initial_options: list = None, + label: str = "Workflow Parameters", + **kwargs, +): + inputs = [] + for p in initial_options: + inputs.append( + Input( + block_id=f"{block_id}-{p['key']}", + element=PlainTextInput( + placeholder="Parameter Value", + action_id=f"{action_id}-{p['key']}", + initial_value=p["value"], + ), + label=p["key"], + **kwargs, + ) + ) + return inputs + + +async def handle_workflow_list_command( + ack: AsyncAck, respond: AsyncRespond, context: AsyncBoltContext, db_session: Session +) -> None: + """Handles the workflow list command.""" + await ack() + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + workflows = incident.workflow_instances + + blocks = [Section(text="*Workflows*")] + for w in workflows: + artifact_links = "" + for a in w.artifacts: + artifact_links += f"- <{a.weblink}|{a.name}> \n" + + blocks.append( + Section( + fields=[ + "*Name:* " + f"\n <{w.weblink}|{w.workflow.name}> \n" + if w.weblink + else "*Name:* " + f"\n {w.workflow.name} \n" + f"*Workflow Description:* \n {w.workflow.description} \n" + f"*Run Reason:* \n {w.run_reason} \n" + f"*Creator:* \n {w.creator.individual.name} \n" + f"*Status:* \n {w.status} \n" + f"*Artifacts:* \n {artifact_links} \n" + ] + ) + ) + + blocks = Message(blocks=blocks).build()["blocks"] + await respond(blocks=blocks, response_type="ephemeral") + + +async def handle_workflow_run_command( + ack: AsyncAck, + body: dict, + client: AsyncWebClient, + context: AsyncBoltContext, + db_session: Session, +) -> None: + """Handles the workflow run command.""" + await ack() + + blocks = [ + Context(elements=[MarkdownText(text="Select a workflow to run.")]), + workflow_select( + db_session=db_session, + dispatch_action=True, + ), + ] + + modal = Modal( + title="Run Workflow", + blocks=blocks, + submit="Run", + close="Close", + callback_id=RunWorkflowActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(view=modal, trigger_id=body["trigger_id"]) + + +@app.view( + RunWorkflowActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_workflow_submission_event( + ack: AsyncAck, + context: AsyncBoltContext, + db_session: Session, + form_data: dict, + user: DispatchUser, +) -> None: + """Handles workflow submission event.""" + await ack() + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + workflow_id = form_data.get(RunWorkflowBlockIds.workflow_select)["value"] + workflow = workflow_service.get(db_session=db_session, workflow_id=workflow_id) + + params = {} + named_params = [] + for i in form_data.keys(): + if i.startswith(RunWorkflowBlockIds.param_input): + key = i.split("-")[1] + value = form_data[i] + params.update({key: value}) + named_params.append({"key": key, "value": value}) + + creator = participant_service.get_by_incident_id_and_email( + db_session=db_session, incident_id=incident.id, email=user.email + ) + + instance = workflow_service.create_instance( + db_session=db_session, + workflow=workflow, + instance_in=WorkflowInstanceCreate( + incident=incident, + creator=creator, + run_reason=form_data[RunWorkflowBlockIds.reason_input], + parameters=named_params, + ), + ) + + for p in instance.parameters: + if p["value"]: + params.update({p["key"]: p["value"]}) + + params.update( + { + "externalRef": f"{DISPATCH_UI_URL}/{instance.incident.project.organization.name}/incidents/{instance.incident.name}?project={instance.incident.project.name}", + "workflowInstanceId": instance.id, + } + ) + + workflow.plugin_instance.instance.run(workflow.resource_id, params) + + # TODO we should move off these types of notification functions and create them directly + send_workflow_notification( + incident.project.id, + incident.conversation.channel_id, + INCIDENT_WORKFLOW_CREATED_NOTIFICATION, + db_session, + instance_creator_name=instance.creator.individual.name, + workflow_name=instance.workflow.name, + workflow_description=instance.workflow.description, + ) + + +@app.action( + RunWorkflowActions.workflow_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_run_workflow_select_action( + ack: AsyncAck, + body: dict, + db_session: Session, + context: AsyncBoltContext, + client: AsyncWebClient, +) -> None: + """Handles workflow select event.""" + await ack() + values = body["view"]["state"]["values"] + workflow_id = values[RunWorkflowBlockIds.workflow_select][RunWorkflowActionIds.workflow_select][ + "selected_option" + ]["value"] + + selected_workflow = workflow_service.get(db_session=db_session, workflow_id=workflow_id) + + blocks = [ + Context(elements=[MarkdownText(text="Select a workflow to run.")]), + workflow_select( + initial_option={"text": selected_workflow.name, "value": selected_workflow.id}, + db_session=db_session, + dispatch_action=True, + ), + reason_input(), + ] + + blocks.extend( + param_input(initial_options=selected_workflow.parameters), + ) + + modal = Modal( + title="Run Workflow", + blocks=blocks, + submit="Run", + close="Close", + callback_id=RunWorkflowActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) diff --git a/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue b/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue index 315cc0e723d4..3e585d861c06 100644 --- a/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue +++ b/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue @@ -41,7 +41,7 @@ export default { headers: [ { text: "Name", value: "workflow.name" }, { text: "Status", value: "status" }, - { text: "Creator", value: "creator" }, + { text: "Creator", value: "creator.individual.name" }, { text: "Run Reason", value: "run_reason" }, { text: "Created At", value: "created_at" }, { text: "", value: "parameters" },