From 2d92e09108f9a1294747ea6084af28e4ab544c87 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 13 Dec 2022 11:21:55 -0800 Subject: [PATCH 01/40] Inital work --- src/dispatch/cli.py | 24 +- .../plugins/dispatch_slack/actions.py | 560 ------ src/dispatch/plugins/dispatch_slack/bolt.py | 67 + .../plugins/dispatch_slack/commands.py | 637 ------- .../plugins/dispatch_slack/decorators.py | 185 -- .../plugins/dispatch_slack/dialogs.py | 201 -- src/dispatch/plugins/dispatch_slack/events.py | 541 ------ .../plugins/dispatch_slack/feedback.py | 159 ++ src/dispatch/plugins/dispatch_slack/fields.py | 515 ++++++ .../plugins/dispatch_slack/incident/enums.py | 150 ++ .../dispatch_slack/incident/interactive.py | 1625 +++++++++++++++++ .../__init__.py => incident/messages.py} | 0 .../plugins/dispatch_slack/listeners.py | 62 + src/dispatch/plugins/dispatch_slack/menus.py | 121 -- .../plugins/dispatch_slack/messaging.py | 62 +- .../plugins/dispatch_slack/middleware.py | 212 +++ .../plugins/dispatch_slack/modals/common.py | 37 - .../modals/feedback/__init__.py | 0 .../modals/feedback/handlers.py | 90 - .../dispatch_slack/modals/feedback/views.py | 97 - .../modals/incident/__init__.py | 0 .../dispatch_slack/modals/incident/enums.py | 58 - .../dispatch_slack/modals/incident/fields.py | 315 ---- .../modals/incident/handlers.py | 453 ----- .../dispatch_slack/modals/incident/views.py | 331 ---- .../modals/workflow/__init__.py | 0 .../modals/workflow/handlers.py | 211 --- .../dispatch_slack/modals/workflow/views.py | 98 - src/dispatch/plugins/dispatch_slack/models.py | 14 +- src/dispatch/plugins/dispatch_slack/plugin.py | 6 +- .../plugins/dispatch_slack/service.py | 151 +- .../plugins/dispatch_slack/socket_mode.py | 78 - .../plugins/dispatch_slack/tests/__init__.py | 0 src/dispatch/plugins/dispatch_slack/views.py | 253 --- .../plugins/dispatch_slack/workflow.py | 278 +++ 35 files changed, 3211 insertions(+), 4380 deletions(-) delete mode 100644 src/dispatch/plugins/dispatch_slack/actions.py create mode 100644 src/dispatch/plugins/dispatch_slack/bolt.py delete mode 100644 src/dispatch/plugins/dispatch_slack/commands.py delete mode 100644 src/dispatch/plugins/dispatch_slack/decorators.py delete mode 100644 src/dispatch/plugins/dispatch_slack/dialogs.py delete mode 100644 src/dispatch/plugins/dispatch_slack/events.py create mode 100644 src/dispatch/plugins/dispatch_slack/feedback.py create mode 100644 src/dispatch/plugins/dispatch_slack/fields.py create mode 100644 src/dispatch/plugins/dispatch_slack/incident/enums.py create mode 100644 src/dispatch/plugins/dispatch_slack/incident/interactive.py rename src/dispatch/plugins/dispatch_slack/{modals/__init__.py => incident/messages.py} (100%) create mode 100644 src/dispatch/plugins/dispatch_slack/listeners.py delete mode 100644 src/dispatch/plugins/dispatch_slack/menus.py create mode 100644 src/dispatch/plugins/dispatch_slack/middleware.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/common.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/feedback/__init__.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/feedback/handlers.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/feedback/views.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/incident/__init__.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/incident/enums.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/incident/fields.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/incident/handlers.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/incident/views.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/workflow/__init__.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/workflow/handlers.py delete mode 100644 src/dispatch/plugins/dispatch_slack/modals/workflow/views.py delete mode 100644 src/dispatch/plugins/dispatch_slack/socket_mode.py delete mode 100644 src/dispatch/plugins/dispatch_slack/tests/__init__.py delete mode 100644 src/dispatch/plugins/dispatch_slack/views.py create mode 100644 src/dispatch/plugins/dispatch_slack/workflow.py diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index e9900e3c849d..0477680a7d02 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -719,13 +719,18 @@ def signals_group(): @click.argument("project") 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 import SocketModeHandler + from dispatch.common.utils.cli import install_plugins + from dispatch.plugins.dispatch_slack import feedback # noqa + 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() @@ -756,8 +761,15 @@ 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() + handler = SocketModeHandler( + app, instance.configuration.socket_mode_app_token.get_secret_value() + ) + handler.start() @dispatch_server.command("shell") 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..30a4b45c57c6 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -0,0 +1,67 @@ +import logging + +from typing import Dict, Any, Optional + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.response import BoltResponse +from slack_bolt.request import BoltRequest +from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler + + +from fastapi import APIRouter + +from starlette.requests import Request +from starlette.responses import Response + +from .listeners import MultiMessageListener + +app = AsyncApp(token="xoxb-valid", raise_error_for_unhandled_request=True) +router = APIRouter() + +# app.use(MultiMessageListener) + +logging.basicConfig(level=logging.DEBUG) + + +@app.error +async def errors(error, body, context, logger, respond): + logger.exception(error) + logger.debug(error) + from pprint import pprint + + pprint(body) + + +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 deleted file mode 100644 index 8b1c04e69bb5..000000000000 --- a/src/dispatch/plugins/dispatch_slack/decorators.py +++ /dev/null @@ -1,185 +0,0 @@ -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__}" - - -def slack_background_task(func): - """Decorator that sets up the a slack background task function - with a database session and exception tracking. - - As background tasks run in their own threads, it does not attempt - to propagate errors. - """ - - @wraps(func) - def wrapper(*args, **kwargs): - background = False - - metrics_provider.counter( - "function.call.counter", tags={"function": fullname(func), "slack": True} - ) - - channel_id = kwargs["channel_id"] - user_id = kwargs["user_id"] - if not kwargs.get("db_session"): - - # 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() - 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 - - 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']}" - - dispatch_slack_service.send_ephemeral_message( - client=kwargs["slack_client"], - conversation_id=channel_id, - user_id=user_id, - text=message, - ) - - 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)) - - # 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}'.""" - - 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 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/feedback.py b/src/dispatch/plugins/dispatch_slack/feedback.py new file mode 100644 index 000000000000..b4f26834cd56 --- /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 = "Feedback Rating", + **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 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 experiance?", + ), + 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 feedbak", "value": "anonymouse"}] + 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..c86ebc8c81d7 --- /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 = "Coordinated Universal Time (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 a 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 a 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 an 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.individual.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..e07565133dee --- /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-input" + hour = "add-timeline-event-hour" + minute = "add-timeline-event-minute" + timezone = "add-timeline-event-timezone" + + +class AddTimelineEventActionIds(DispatchEnum): + date = "add-timeline-event-input" + 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 = "update-task-event" + + +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..371c14bee422 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -0,0 +1,1625 @@ +from datetime import datetime +from typing import List + +import pytz +from blockkit import ( + Actions, + Button, + Checkboxes, + Context, + Divider, + Image, + Input, + MarkdownText, + Message, + Modal, + PlainOption, + PlainTextInput, + Section, + UsersSelect, +) +from sqlalchemy import func + +from dispatch.database.core import resolve_attr +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.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.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.middleware import ( + action_context_middleware, + command_context_middleware, + configuration_context_middleware, + db_middleware, + message_context_middleware, + modal_submit_middleware, + user_middleware, + restricted_command_middleware, +) +from dispatch.plugins.dispatch_slack.models import SubjectMetadata +from dispatch.plugins.dispatch_slack.service import ( + 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 + + +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, + configuration_context_middleware, + user_middleware, + ] + + # 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_incidents, middleware=middleware)( + handle_list_incidents_command + ) + app.command(config.slack_command_report_incident, middleware=middleware)( + handle_report_incident_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_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 because allow the user to change the reaction string + app.event(config.timeline_event_reaction, middleware=[db_middleware])( + handle_timeline_added_event + ) + + +@app.options( + DefaultActionIds.tags_multi_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_tag_search_action(ack, payload, context, db_session): + """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 not 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_project_select_action(ack, body, client, context, db_session): + 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 incident details.")]), + title_input(initial_value=incident.title), + description_input(initial_value=incident.description), + resolution_input(initial_value=incident.resolution), + 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_priority_select( + db_session=db_session, + initial_option={ + "text": incident.incident_priority.name, + "value": incident.incident_priority.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, + ), + tag_multi_select( + optional=True, + initial_options=[t.name for t in incident.tags], + ), + ] + + modal = Modal( + title="Update Incident", + blocks=blocks, + submit="Submit", + 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, body, respond, db_session, context): + """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 = body["command"]["text"].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 = [Context(text="Incident List")] + + if incidents: + for incident in incidents: + if incident.visibility == Visibility.open: + blocks.append( + Section( + fields=[ + f"*<{incident.ticket.weblink}|{incident.name}>*", + f"*Title*:\n {incident.title}", + f"*Type*:\n {incident.incident_type.name}", + f"*Severity*:\n {incident.incident_severity.name}", + f"*Priority*:\n {incident.incident_priority.name}", + f"*Status*:\n{incident.status}", + f"*Incident Commander*:\n<{incident.commander.individual.weblink}|{incident.commander.individual.name}>", + f"*Project*:\n{incident.project.name}", + ] + ) + ) + + blocks = Message(blocks=blocks).build()["blocks"] + await respond(text="Incident List", blocks=blocks, response_type="ephemeral") + + +async def handle_list_participants_command(ack, body, respond, client, db_session, context): + """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" + ) + + 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): + """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, user, body, respond, context, db_session): + """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, body, respond, client, db_session, context, logger): + """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]}..." + ) + + blocks = [ + Section(text=f"*<{incident.title}|https://google.com>*"), + Section(text=f"*Description* \n {incident_description}"), + Section( + text=f"*Commander* \n <{incident.commander.individual.weblink}|{incident.commander.individual.name}>" + ), + Section( + text=f"*Reporter* \n <{incident.reporter.individual.weblink}|{incident.reporter.individual.name}>" + ), + ] + + if resolve_attr(incident, "incident_document.weblink"): + blocks.append(Section(text=f"*<{incident.incident_document.weblink}|Incident Document>*")) + + if resolve_attr(incident, "storage.weblink"): + blocks.append(Section(text=f"*<{incident.storage.weblink}|Storage>*")) + + if resolve_attr(incident, "ticket.weblink"): + blocks.append(Section(text=f"*<{incident.ticket.weblink}|Ticket>*")) + + if resolve_attr(incident, "conference.weblink"): + blocks.append(Section(text=f"*<{incident.conference.weblink}|Conference>*")) + + if resolve_attr(incident, "incident_review_document"): + blocks.append( + Section(text=f"*<{incident.incident_review_document}|Incident Review Document>(") + ) + + faq_doc = document_service.get_incident_faq_document( + db_session=db_session, project_id=incident.project_id + ) + if faq_doc: + blocks.append(Section(text=f"*<{faq_doc.weblink}|FAQ Document>*")) + + conversation_reference = document_service.get_conversation_reference_document( + db_session=db_session, project_id=incident.project_id + ) + if conversation_reference: + blocks.append(Section(text=f"*<{conversation_reference.weblink}|Command Reference>*")) + + blocks = Message(blocks=blocks).build()["blocks"] + await respond(text="Incident Resources Message", blocks=blocks, response_type="ephemeral") + + +# EVENTS + + +async def handle_timeline_added_event(ack, body, client, context, db_session): + """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 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, + ) + + +@app.event( + {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] +) +async def handle_participant_role_activity(ack, db_session, context, user): + """Increments the participant role's activity counter.""" + 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: + active_participant_roles = participant.active_roles + for participant_role in active_participant_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, + ) + + +@app.event( + {"type": "message"}, middleware=[message_context_middleware, user_middleware, db_middleware] +) +async def handle_after_hours_message(ack, context, body, client, respond, user, db_session): + """Notifies the user that this incident is current in after hours mode.""" + # we ignore user channel and group join messages + await ack() + + if body["subtype"] in ["channel_join", "group_join"]: + return + + if context["subject"].type == "case": + return + + # get their timezone from slack + elif 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 = dispatch_slack_service.get_user_info_by_email(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)." + + now = datetime.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: + blocks = [Section(text=message)] + participant.after_hours_notification = True + db_session.add(participant) + db_session.commit() + blocks = Message(blocks=blocks).build()["blocks"] + await respond(blocks=blocks, response_type="ephemeral") + + +@app.event("member_joined", middleware=[action_context_middleware, user_middleware, db_middleware]) +async def handle_member_joined_channel(ack, user, body, client, db_session, context): + """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 + ) + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + if body["inviter"]: + inviter_email = await get_user_email_async(client=client, user_id=body["inviter"]) + client.user + 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 + participant.added_reason = body["text"] + else: + participant.added_by = incident.commander + participant.added_reason = f"Participant added by {added_by_participant.individual.name}" + + db_session.add(participant) + db_session.commit() + + +@app.event("member_left", middleware=[action_context_middleware, db_middleware]) +async def handle_member_left_channel(ack, context, db_session, user): + await ack() + incident_flows.incident_remove_participant_flow( + user.email, context["subject"].id, db_session=db_session + ) + + +@app.event( + {"type": "message", "subtype": "message_replied"}, middleware=[action_context_middleware] +) +async def handle_thread_creation(ack, respond, client, context): + """Sends the user an ephemeral message if they use threads.""" + # if not context["config"].ban_threads: + # return + + message = "Please refrain from using threads in incident related channels. Threads make it harder for incident participants to maintain context." + await respond(text=message, response_type="ephemeral") + + +@app.event({"type": "message"}, middleware=[message_context_middleware, db_middleware]) +async def handle_message_tagging(ack, db_session, context): + """Looks for incident tags in incident messages.""" + + # TODO handle case tagging + if context["subject"].type == "incident": + text = context["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() + + +@app.event({"type": "message"}, middleware=[message_context_middleware, db_middleware]) +async def handle_message_monitor(ack, respond, body, context, db_session): + """Looks strings that are available for monitoring (usually links).""" + await ack() + # TODO handle cases + if context["subject"].type == "incident": + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + project_id = incident.project.id + button_metadata = MonitorMetadata( + type="incident", + organization_slug=incident.project.organization.slug, + id=incident.id, + project_id=incident.project.id, + channel_id=context["channel_id"], + ) + + else: + 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(body["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.weblink = match_data["weblink"] + + blocks = [ + Section( + text="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?" + ), + 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 respond(blocks=blocks, response_type="ephemeral") + + +# MODALS +async def handle_add_timeline_event_command(ack, body, client, context): + """Handles the add timeline event command.""" + await ack() + blocks = [ + Context( + elements=[MarkdownText(text="Use this form to add an event to the incident 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, + ) + + +@app.view( + AddTimelineEventActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_add_timeline_submission_event(ack, user, client, context, db_session, form_data): + """Handles the add timeline submission event.""" + 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, + ) + + blocks = [Section(text="Success!")] + + modal = Modal( + title="Timeline Event Added", + close="Close", + blocks=blocks, + private_metadata=context["subject"].json(), + ).build() + + await ack(response_action="update", view=modal) + + +async def handle_update_participant_command(ack, respond, body, context, db_session, client): + """Handles the update participant command.""" + await ack() + if context["subject"].type == "case": + await respond(text="This command is not yet supported in the case context.") + + 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="Submit", + close="Cancel", + callback_id=UpdateParticipantActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + UpdateParticipantActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_update_participant_submission_event( + ack, user, client, context, db_session, form_data +): + """Handles the update participant submission event.""" + ack() + + +async def handle_update_notifications_group_command(ack, body, context, client, db_session): + """Handles the update notification group command.""" + await ack() + + # TODO handle cases + if context["subject"].type == "case": + 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" + ) + 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( + text=", ".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 Membership", + blocks=blocks, + close="Cancel", + submit="Submit", + callback_id=UpdateNotificationGroupActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + UpdateNotificationGroupActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_update_notifications_group_submission_event( + ack, user, client, context, db_session, form_data +): + """Handles the update notifications group submission""" + ack() + + +async def handle_assign_role_command(ack, context, body, client): + """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: User will be invited to 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) + + +@app.view( + AssignRoleActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_assign_role_submission_event(ack, user, client, context, db_session, form_data): + """Handles the assign role submission.""" + 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 + ): + # we update the external ticket + incident_flows.update_external_incident_ticket( + incident_id=context["subject"].id, db_session=db_session + ) + + modal = Modal(title="Engagement", blocks=[Section(text="Success!")], close="Close").build() + await ack(response_action="update", view=modal) + + +async def handle_engage_oncall_command(ack, respond, context, body, client, db_session): + """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) + + +@app.view( + EngageOncallActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_engage_oncall_submission_event(ack, user, context, db_session, form_data): + """Handles the engage oncall submission""" + await ack() + + 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 ack(response_action="update", view=modal) + + +async def handle_report_tactical_command(ack, client, respond, context, db_session, body): + """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="Submit", + close="Close", + callback_id=ReportTacticalActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + ReportTacticalActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_report_tactical_submission_event( + ack, user, client, context, db_session, form_data +): + """Handles the report tactical submission""" + await ack() + + 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, + db_session=db_session, + ) + + +async def handle_report_executive_command(ack, body, client, respond, context, db_session): + """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 incident 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="Current incident needs", 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="Submit", + close="Close", + callback_id=ReportExecutiveActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + ReportExecutiveActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_report_executive_submission_event( + user, client, body, context, db_session, form_data +): + """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], + ) + + modal = Modal( + title="Executive Report", + close="Close", + blocks=[Section(text="Creating report and sending it to recipients...")], + ).build() + + stack = await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + + report_flows.create_executive_report( + user_email=user.email, + incident_id=context["subject"].id, + executive_report_in=executive_report_in, + db_session=db_session, + ) + + modal = Modal( + title="Executive Report", + close="Close", + blocks=[Section(text="Creating report and sending it to recipients... Success!")], + ).build() + + await client.views_update( + view_id=stack["view"]["id"], + hash=stack["view"]["hash"], + trigger_id=stack["trigger_id"], + view=modal, + ) + + +async def handle_update_incident_command(ack, body, client, context, db_session): + """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 incident 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_priority_select( + db_session=db_session, + initial_option={ + "text": incident.incident_priority.name, + "value": incident.incident_priority.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, + ), + 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="Submit", + close="Cancel", + callback_id=IncidentUpdateActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + IncidentUpdateActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_update_incident_submission_event( + ack, body, client, user, context, db_session, form_data +): + """Handles the update incident submission""" + 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 + ) + + modal = Modal( + title="Incident Update", + close="Close", + blocks=[Section(text="The incident is being updated...")], + ).build() + + stack = await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + + 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="The incident has been successfully updated!")], + ).build() + + await client.views_update( + view_id=stack["view"]["id"], + hash=stack["view"]["hash"], + trigger_id=stack["trigger_id"], + view=modal, + ) + + +async def handle_report_incident_command(ack, body, context, db_session, client): + """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="Submit", + close="Cancel", + callback_id=IncidentReportActions.submit, + private_metadata=context["subject"].json(), + ).build() + + await client.views_open(trigger_id=body["trigger_id"], view=modal) + + +@app.view( + IncidentReportActions.submit, + middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], +) +async def handle_report_incident_submission_event(ack, user, client, body, db_session, form_data): + """Handles the report incident submission""" + await ack() + 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_in = IncidentCreate( + title=form_data[DefaultBlockIds.title_input], + description=form_data[DefaultBlockIds.description_input], + incident_type={"name": form_data[DefaultBlockIds.incident_type_select]["name"]}, + incident_priority={"name": form_data[DefaultBlockIds.incident_priority_select]["name"]}, + project=project, + reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), + tags=tags, + ) + + # 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 a incident with the following information. You will be invited to an incident slack conversation shortly." + ), + Section(text=f"*Incident Title*\n {incident.title}"), + Section(text=f"*Description*\n {incident.description}"), + Section( + fields=[ + MarkdownText(text=f"*Commander* \n {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() + + await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + + incident_flows.incident_create_flow( + incident_id=incident.id, + db_session=db_session, + organization_slug=incident.project.organization.slug, + ) + + +@app.action( + IncidentReportActions.project_select, middleware=[action_context_middleware, db_middleware] +) +async def handle_report_incident_project_select_action(ack, body, client, context, db_session): + 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 incident 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_priority_select(db_session=db_session, project_id=project.id, optional=True), + incident_severity_select(db_session=db_session, project_id=project.id, optional=True), + tag_multi_select(optional=True), + ] + + modal = Modal( + title="Report Incident", + blocks=blocks, + submit="Submit", + 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/listeners.py b/src/dispatch/plugins/dispatch_slack/listeners.py new file mode 100644 index 000000000000..eee78c037264 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/listeners.py @@ -0,0 +1,62 @@ +from logging import Logger + +from typing import Optional, Callable, Sequence + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.listener_matcher import ListenerMatcher +from slack_bolt.listener import Listener +from slack_bolt.middleware import Middleware +from slack_bolt.logger import get_bolt_app_logger +from slack_bolt.util.utils import get_arg_names_of_callable + +from slack_bolt.response import BoltResponse +from slack_bolt.request import BoltRequest + + +class MultiMessageListener(Listener): + """This listener enables multiple functions to listen to the same message.""" + + app_name: str + ack_function: Callable[..., Optional[BoltResponse]] + lazy_functions: Sequence[Callable[..., None]] + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] # type: ignore + auto_acknowledgement: bool + arg_names: Sequence[str] + logger: Logger + + def __init__( + self, + *, + app_name: str, + ack_function: Callable[..., Optional[BoltResponse]], + lazy_functions: Sequence[Callable[..., None]], + matchers: Sequence[ListenerMatcher], + middleware: Sequence[Middleware], # type: ignore + auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, + ): + self.app_name = app_name + self.ack_function = ack_function + self.lazy_functions = lazy_functions + self.matchers = matchers + self.middleware = middleware + self.auto_acknowledgement = auto_acknowledgement + self.arg_names = get_arg_names_of_callable(ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) + + def run_ack_function( + self, + *, + request: BoltRequest, + response: BoltResponse, + ) -> Optional[BoltResponse]: + return self.ack_function( + **build_required_kwargs( + logger=self.logger, + required_arg_names=self.arg_names, + request=request, + response=response, + this_func=self.ack_function, + ) + ) 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..e4c79fa75685 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -146,41 +146,6 @@ def create_command_run_in_conversation_where_bot_not_present_message( } -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 = { @@ -244,14 +209,18 @@ def default_notification(items: list): block = {"type": "actions", "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"], - } - ) + element = { + "action_id": button["button_action"], + "type": "button", + "text": {"type": "plain_text", "text": button["button_text"]}, + "value": button["button_value"], + } + + if button.get("button_url"): + element.update({"url": button["button_url"]}) + + block["elements"].append(element) + blocks.append(block) return blocks @@ -277,8 +246,11 @@ def create_message_blocks( blocks.append({"type": "section", "text": {"type": "mrkdwn", "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..ef1a0c6bb5c2 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -0,0 +1,212 @@ +from typing import Optional + +from sqlalchemy.orm.session import Session + +from dispatch.auth import service as user_service +from dispatch.auth.models import 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 .models import SubjectMetadata + + +def resolve_conversation_from_context( + channel_id: str, message_ts: 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(body, context, next): + """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, context, next): + """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, context, next): + """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, next): + """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 Exception("Unable to determine context.") + + await next() + + +async def restricted_command_middleware(context, db_session, user, next): + """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 Exception("Unauthorized.") + + +async def user_middleware(body, payload, db_session, client, context, next): + """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"] + + 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=DispatchUser(email=email), + ) + await next() + + +async def modal_submit_middleware(body, context, next): + """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() + + +# TODO determine how we an get the current slack config +async def configuration_context_middleware(context, db_session, next): + context["config"] = {} # SlackConversationConfiguration() + await next() + + +async def command_context_middleware(context, next): + 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 Exception("Unable to determine context.") + + await next() + + +async def db_middleware(context, next): + 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() 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..0ac980f0be36 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -22,7 +22,7 @@ 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, @@ -85,8 +85,10 @@ def send( if not blocks: blocks = create_message_blocks(message_template, notification_type, items, **kwargs) + 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, diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 5c2656d94b19..0303952ce922 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,19 +318,6 @@ 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) @@ -392,24 +452,3 @@ def message_filter(message): return return message - - -def is_user(config: SlackConversationConfiguration, user_id: str): - """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..b35b96a4a276 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/workflow.py @@ -0,0 +1,278 @@ +from blockkit import Context, Input, Section, MarkdownText, Modal, PlainTextInput, Message + +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, + project_id: int, + 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, project_id=project_id) + + 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, body, respond, client, context, db_session): + """Handles the workflow list command.""" + 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=[ + f"*Name:* \n <{w.weblink}|{w.workflow.name}>" + f"*Workflow Description:* \n {w.workflow.description}" + f"*Run Reason:* \n {w.run_reason}" + f"*Creator:* \n {w.creator.individual.name}" + f"*Status:* \n {w.status}" + f"*Artifacts:* \n {artifact_links}" + ] + ) + ) + + blocks = Message(blocks=blocks).build()["blocks"] + await respond(blocks=blocks, response_type="ephemeral") + + +async def handle_workflow_run_command( + ack, + body, + client, + context, + db_session, +): + """Handles the workflow run command.""" + await ack() + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + blocks = [ + Context(elements=[MarkdownText(text="Select a workflow to run.")]), + workflow_select( + db_session=db_session, dispatch_action=True, project_id=incident.project.id + ), + ] + + 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, body, client, context, db_session, form_data, user): + """Handles workflow submission event.""" + 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, + instance_in=WorkflowInstanceCreate( + workflow=workflow, + 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, body, db_session, context, client): + """Handles workflow select event.""" + await ack() + values = body["view"]["state"]["values"] + workflow_id = values[RunWorkflowBlockIds.workflow_select][RunWorkflowActionIds.workflow_select][ + "selected_option" + ]["value"] + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + 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, + project_id=incident.project.id, + ), + 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, + ) From 7a1933a0ca41d4ba4f488fb5560629f3d5ca2f0b Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 13 Dec 2022 12:51:28 -0800 Subject: [PATCH 02/40] More bug fixes --- src/dispatch/cli.py | 16 +- src/dispatch/plugins/dispatch_slack/bolt.py | 10 - .../dispatch_slack/incident/interactive.py | 205 +++++++++++------- .../plugins/dispatch_slack/listeners.py | 62 ------ .../plugins/dispatch_slack/messaging.py | 84 ------- 5 files changed, 142 insertions(+), 235 deletions(-) delete mode 100644 src/dispatch/plugins/dispatch_slack/listeners.py diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 0477680a7d02..a0f94db674d2 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -719,9 +719,10 @@ def signals_group(): @click.argument("project") def run_slack_websocket(organization: str, project: str): """Runs the slack websocket process.""" + import asyncio from sqlalchemy import true - from slack_bolt.adapter.socket_mode import SocketModeHandler + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from dispatch.common.utils.cli import install_plugins from dispatch.plugins.dispatch_slack import feedback # noqa @@ -765,11 +766,16 @@ def run_slack_websocket(organization: str, project: str): click.secho("Slack websocket process started...", fg="blue") incident_configure(instance.configuration) workflow_configure(instance.configuration) + app._token = instance.configuration.api_bot_token.get_secret_value() - handler = SocketModeHandler( - app, instance.configuration.socket_mode_app_token.get_secret_value() - ) - handler.start() + + 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/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 30a4b45c57c6..5e5df29dffc2 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,25 +1,15 @@ import logging - -from typing import Dict, Any, Optional - from slack_bolt.app.async_app import AsyncApp -from slack_bolt.response import BoltResponse -from slack_bolt.request import BoltRequest from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from fastapi import APIRouter from starlette.requests import Request -from starlette.responses import Response - -from .listeners import MultiMessageListener app = AsyncApp(token="xoxb-valid", raise_error_for_unhandled_request=True) router = APIRouter() -# app.use(MultiMessageListener) - logging.basicConfig(level=logging.DEBUG) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 371c14bee422..868c18bdd062 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1,6 +1,8 @@ +import logging from datetime import datetime from typing import List +import inspect import pytz from blockkit import ( Actions, @@ -107,6 +109,8 @@ from dispatch.task.enums import TaskStatus from dispatch.task.models import Task +log = logging.getLogger(__file__) + class TaskMetadata(SubjectMetadata): resource_id: str @@ -116,6 +120,49 @@ class MonitorMetadata(SubjectMetadata): weblink: str +class MessageDispatcher: + """Dispatches current message to any registered function.""" + + registered_funcs = [] + + def add(self, *args, **kwargs): + """Adds a function to the dispatcher.""" + + def decorator(func): + if not kwargs.get("name"): + name = func.__name__ + else: + name = kwargs.pop("name") + + self.registered_funcs.append({"name": name, "func": func}) + + return decorator + + 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) + + try: + await f["func"](*injected_args) + except Exception as e: + log.exception(e) + log.debug(f"Failed to run dispatched function ({e})") + + +message_dispatcher = MessageDispatcher() + + +@app.event( + {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] +) +async def handle_message_events(ack, payload, context, body, client, respond, user, db_session): + """Container function for all message functions.""" + await message_dispatcher.dispatch(**locals()) + + def configure(config): """Maps commands/events to their functions.""" middleware = [ @@ -362,7 +409,7 @@ async def handle_list_incidents_command(ack, body, respond, db_session, context) await respond(text="Incident List", blocks=blocks, response_type="ephemeral") -async def handle_list_participants_command(ack, body, respond, client, db_session, context): +async def handle_list_participants_command(ack, respond, client, db_session, context): """Handles list participants command.""" await ack() blocks = [Section(text="*Incident Participants*")] @@ -439,7 +486,7 @@ def filter_tasks_by_assignee_and_creator(tasks: List[Task], by_assignee: str, by return filtered_tasks -async def handle_list_tasks_command(ack, user, body, respond, context, db_session): +async def handle_list_tasks_command(ack, user, respond, context, db_session): """Handles the list tasks command.""" await ack() blocks = [] @@ -494,7 +541,7 @@ async def handle_list_tasks_command(ack, user, body, respond, context, db_sessio await respond(text="Incident Task List", blocks=message, response_type="ephermeral") -async def handle_list_resources_command(ack, body, respond, client, db_session, context, logger): +async def handle_list_resources_command(ack, respond, db_session, context): """Handles the list resources command.""" await ack() incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) @@ -552,7 +599,7 @@ async def handle_list_resources_command(ack, body, respond, client, db_session, # EVENTS -async def handle_timeline_added_event(ack, body, client, context, db_session): +async def handle_timeline_added_event(client, context, db_session): """Handles an event where a reaction is added to a message.""" conversation_id = context["channel_id"] message_ts = context["ts"] @@ -587,9 +634,7 @@ async def handle_timeline_added_event(ack, body, client, context, db_session): ) -@app.event( - {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] -) +@message_dispatcher.add() async def handle_participant_role_activity(ack, db_session, context, user): """Increments the participant role's activity counter.""" await ack() @@ -630,17 +675,13 @@ async def handle_participant_role_activity(ack, db_session, context, user): ) -@app.event( - {"type": "message"}, middleware=[message_context_middleware, user_middleware, db_middleware] -) -async def handle_after_hours_message(ack, context, body, client, respond, user, db_session): +@message_dispatcher.add(exclude={"subtype": ["channel_join", "group_join"]}) +async def handle_after_hours_message(ack, context, client, payload, user, db_session): """Notifies the user that this incident is current in after hours mode.""" # we ignore user channel and group join messages await ack() - if body["subtype"] in ["channel_join", "group_join"]: - return - + # TODO add case support if context["subject"].type == "case": return @@ -653,76 +694,52 @@ async def handle_after_hours_message(ack, context, body, client, respond, user, ) # get their timezone from slack - owner_tz = dispatch_slack_service.get_user_info_by_email(client, email=owner_email)["tz"] + 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)." - now = datetime.datetime.now(pytz.timezone(owner_tz)) + 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: - blocks = [Section(text=message)] participant.after_hours_notification = True db_session.add(participant) db_session.commit() - blocks = Message(blocks=blocks).build()["blocks"] - await respond(blocks=blocks, response_type="ephemeral") - - -@app.event("member_joined", middleware=[action_context_middleware, user_middleware, db_middleware]) -async def handle_member_joined_channel(ack, user, body, client, db_session, context): - """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 - ) - - incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) - - if body["inviter"]: - inviter_email = await get_user_email_async(client=client, user_id=body["inviter"]) - client.user - 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 - participant.added_reason = body["text"] - else: - participant.added_by = incident.commander - participant.added_reason = f"Participant added by {added_by_participant.individual.name}" - - db_session.add(participant) - db_session.commit() - - -@app.event("member_left", middleware=[action_context_middleware, db_middleware]) -async def handle_member_left_channel(ack, context, db_session, user): - await ack() - incident_flows.incident_remove_participant_flow( - user.email, context["subject"].id, db_session=db_session - ) + await client.chat_postEphemeral( + text=message, + channel=payload["channel"], + user=payload["user"], + ) -@app.event( - {"type": "message", "subtype": "message_replied"}, middleware=[action_context_middleware] -) -async def handle_thread_creation(ack, respond, client, context): +@message_dispatcher.add() +async def handle_thread_creation(client, payload, context): """Sends the user an ephemeral message if they use threads.""" + # TODO figure out how to pass current slack config # if not context["config"].ban_threads: # return - message = "Please refrain from using threads in incident related channels. Threads make it harder for incident participants to maintain context." - await respond(text=message, response_type="ephemeral") + if context["subject"].type == "incident": + if payload.get("thread_ts"): + message = "Please refrain from using threads in incident related 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"], + ) -@app.event({"type": "message"}, middleware=[message_context_middleware, db_middleware]) -async def handle_message_tagging(ack, db_session, context): +@message_dispatcher.add() +async def handle_message_tagging(db_session, payload, context): """Looks for incident tags in incident messages.""" # TODO handle case tagging if context["subject"].type == "incident": - text = context["text"] + 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] @@ -740,21 +757,14 @@ async def handle_message_tagging(ack, db_session, context): db_session.commit() -@app.event({"type": "message"}, middleware=[message_context_middleware, db_middleware]) -async def handle_message_monitor(ack, respond, body, context, db_session): +@message_dispatcher.add() +async def handle_message_monitor(ack, payload, context, client, db_session): """Looks strings that are available for monitoring (usually links).""" await ack() # TODO handle cases if context["subject"].type == "incident": incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) project_id = incident.project.id - button_metadata = MonitorMetadata( - type="incident", - organization_slug=incident.project.organization.slug, - id=incident.id, - project_id=incident.project.id, - channel_id=context["channel_id"], - ) else: return @@ -765,7 +775,7 @@ async def handle_message_monitor(ack, respond, body, context, db_session): for p in plugins: for matcher in p.instance.get_matchers(): - for match in matcher.finditer(body["text"]): + 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"] @@ -781,7 +791,14 @@ async def handle_message_monitor(ack, respond, body, context, db_session): for k, v in current_status.items(): status_text += f"*{k.title()}*:\n{v.title()}\n" - button_metadata.weblink = match_data["weblink"] + 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( @@ -807,7 +824,47 @@ async def handle_message_monitor(ack, respond, body, context, db_session): ), ] blocks = Message(blocks=blocks).build()["blocks"] - await respond(blocks=blocks, response_type="ephemeral") + await client.chat_postEphemeral( + text="Link Monitor", + channel=payload["channel"], + thread_ts=payload["thread_ts"], + blocks=blocks, + user=payload["user"], + ) + + +@app.event("member_joined", middleware=[action_context_middleware, user_middleware, db_middleware]) +async def handle_member_joined_channel(ack, user, body, client, db_session, context): + """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 + ) + + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + + if body["inviter"]: + inviter_email = await get_user_email_async(client=client, user_id=body["inviter"]) + client.user + 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 + participant.added_reason = body["text"] + else: + participant.added_by = incident.commander + participant.added_reason = f"Participant added by {added_by_participant.individual.name}" + + db_session.add(participant) + db_session.commit() + + +@app.event("member_left", middleware=[action_context_middleware, db_middleware]) +async def handle_member_left_channel(ack, context, db_session, user): + await ack() + incident_flows.incident_remove_participant_flow( + user.email, context["subject"].id, db_session=db_session + ) # MODALS diff --git a/src/dispatch/plugins/dispatch_slack/listeners.py b/src/dispatch/plugins/dispatch_slack/listeners.py deleted file mode 100644 index eee78c037264..000000000000 --- a/src/dispatch/plugins/dispatch_slack/listeners.py +++ /dev/null @@ -1,62 +0,0 @@ -from logging import Logger - -from typing import Optional, Callable, Sequence - -from slack_bolt.kwargs_injection import build_required_kwargs -from slack_bolt.listener_matcher import ListenerMatcher -from slack_bolt.listener import Listener -from slack_bolt.middleware import Middleware -from slack_bolt.logger import get_bolt_app_logger -from slack_bolt.util.utils import get_arg_names_of_callable - -from slack_bolt.response import BoltResponse -from slack_bolt.request import BoltRequest - - -class MultiMessageListener(Listener): - """This listener enables multiple functions to listen to the same message.""" - - app_name: str - ack_function: Callable[..., Optional[BoltResponse]] - lazy_functions: Sequence[Callable[..., None]] - matchers: Sequence[ListenerMatcher] - middleware: Sequence[Middleware] # type: ignore - auto_acknowledgement: bool - arg_names: Sequence[str] - logger: Logger - - def __init__( - self, - *, - app_name: str, - ack_function: Callable[..., Optional[BoltResponse]], - lazy_functions: Sequence[Callable[..., None]], - matchers: Sequence[ListenerMatcher], - middleware: Sequence[Middleware], # type: ignore - auto_acknowledgement: bool = False, - base_logger: Optional[Logger] = None, - ): - self.app_name = app_name - self.ack_function = ack_function - self.lazy_functions = lazy_functions - self.matchers = matchers - self.middleware = middleware - self.auto_acknowledgement = auto_acknowledgement - self.arg_names = get_arg_names_of_callable(ack_function) - self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) - - def run_ack_function( - self, - *, - request: BoltRequest, - response: BoltResponse, - ) -> Optional[BoltResponse]: - return self.ack_function( - **build_required_kwargs( - logger=self.logger, - required_arg_names=self.arg_names, - request=request, - response=response, - this_func=self.ack_function, - ) - ) diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index e4c79fa75685..0127f6c9873a 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -17,83 +17,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( @@ -113,16 +39,6 @@ def get_incident_conversation_command_message(config: SlackConfiguration, comman ).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 { From 85f338d988aa72fe0ce5a9f12572a7c1175812e3 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 13 Dec 2022 14:36:55 -0800 Subject: [PATCH 03/40] Adding list participants --- src/dispatch/plugins/dispatch_slack/bolt.py | 21 +++++++++++++++---- .../dispatch_slack/incident/interactive.py | 20 +++++++++++------- .../plugins/dispatch_slack/middleware.py | 17 +++++++++++---- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 5e5df29dffc2..bcbb106b3977 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,12 +1,15 @@ import logging from slack_bolt.app.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler +from slack_bolt.response import BoltResponse from fastapi import APIRouter from starlette.requests import Request +from .exceptions import ContextError, RoleError + app = AsyncApp(token="xoxb-valid", raise_error_for_unhandled_request=True) router = APIRouter() @@ -14,12 +17,22 @@ @app.error -async def errors(error, body, context, logger, respond): +async def errors(error, payload, client, respond, logger): + + print(error) + + message = "An unknown error has occured." + if isinstance(error, ContextError): + message = str(error) + + elif isinstance(error, RoleError): + message = str(error) + + await respond(text=message, response_type="ephemeral") + logger.exception(error) logger.debug(error) - from pprint import pprint - - pprint(body) + return BoltResponse(status=200, body="") handler = AsyncSlackRequestHandler(app) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 868c18bdd062..9de1a29d0863 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -22,6 +22,7 @@ ) from sqlalchemy import func +from dispatch.config import DISPATCH_UI_URL from dispatch.database.core import resolve_attr from dispatch.database.service import search_filter_sort_paginate from dispatch.document import service as document_service @@ -172,6 +173,11 @@ def configure(config): user_middleware, ] + # don't need an incident context + app.command(config.slack_command_list_incidents, middleware=[db_middleware])( + handle_list_incidents_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)( @@ -183,9 +189,6 @@ def configure(config): app.command(config.slack_command_update_participant, middleware=middleware)( handle_update_participant_command ) - app.command(config.slack_command_list_incidents, middleware=middleware)( - handle_list_incidents_command - ) app.command(config.slack_command_report_incident, middleware=middleware)( handle_report_incident_command ) @@ -332,7 +335,7 @@ async def handle_project_select_action(ack, body, client, context, db_session): # COMMANDS -async def handle_list_incidents_command(ack, body, respond, db_session, context): +async def handle_list_incidents_command(ack, payload, respond, db_session, context): """Handles the list incidents command.""" await ack() projects = [] @@ -343,7 +346,7 @@ async def handle_list_incidents_command(ack, body, respond, db_session, context) projects.append(incident.project) else: # command was run in a non-incident conversation - args = body["command"]["text"].split(" ") + args = payload["command"].split(" ") if len(args) == 2: project = project_service.get_by_name(db_session=db_session, name=args[1]) @@ -385,15 +388,17 @@ async def handle_list_incidents_command(ack, body, respond, db_session, context) ) ) - blocks = [Context(text="Incident List")] + 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"*<{incident.ticket.weblink}|{incident.name}>*", f"*Title*:\n {incident.title}", f"*Type*:\n {incident.incident_type.name}", f"*Severity*:\n {incident.incident_severity.name}", @@ -404,6 +409,7 @@ async def handle_list_incidents_command(ack, body, respond, db_session, context) ] ) ) + blocks.append(Divider()) blocks = Message(blocks=blocks).build()["blocks"] await respond(text="Incident List", blocks=blocks, response_type="ephemeral") diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py index ef1a0c6bb5c2..38f6826dbf6f 100644 --- a/src/dispatch/plugins/dispatch_slack/middleware.py +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -12,6 +12,7 @@ from dispatch.participant_role.enums import ParticipantRoleType from .models import SubjectMetadata +from .exceptions import ContextError, RoleError def resolve_conversation_from_context( @@ -76,7 +77,7 @@ async def message_context_middleware(context, next): } ) else: - raise Exception("Unable to determine context.") + raise ContextError("Unable to determine context for message.") await next() @@ -94,7 +95,9 @@ async def restricted_command_middleware(context, db_session, user, next): if active_role.role == allowed_role: return await next() - raise Exception("Unauthorized.") + raise RoleError( + f"User does not have correct role. allowedRoles: {','.join(['r.name for r in allowed_roles'])}" + ) async def user_middleware(body, payload, db_session, client, context, next): @@ -112,6 +115,9 @@ async def user_middleware(body, payload, db_session, client, context, next): if payload.get("user_id"): user_id = payload["user_id"] + if not user_id: + raise ContextError("Unabled 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, @@ -172,7 +178,8 @@ async def configuration_context_middleware(context, db_session, next): await next() -async def command_context_middleware(context, next): +# NOTE we don't need to handle cases because commands are not available in threads. +async def command_context_middleware(context, payload, next): conversation = resolve_conversation_from_context(channel_id=context["channel_id"]) if conversation: context.update( @@ -186,7 +193,9 @@ async def command_context_middleware(context, next): } ) else: - raise Exception("Unable to determine context.") + 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() From 7eee62d1c9c6667bc4c393492e89d0047c7a338e Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 13 Dec 2022 14:38:34 -0800 Subject: [PATCH 04/40] Moving error messages --- .../plugins/dispatch_slack/messaging.py | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index 0127f6c9873a..a85d9f53ba71 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -6,7 +6,6 @@ """ import logging from typing import List, Optional -from jinja2 import Template from dispatch.messaging.strings import ( EVERGREEN_REMINDER_DESCRIPTION, @@ -20,48 +19,6 @@ log = logging.getLogger(__name__) -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_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 get_template(message_type: MessageType): """Fetches the correct template based on message type.""" template_map = { From 1c20dad6089fdac2ca7d44b982eff32e4378fa56 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 13 Dec 2022 14:42:35 -0800 Subject: [PATCH 05/40] Adds exceptions --- src/dispatch/plugins/dispatch_slack/exceptions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/dispatch/plugins/dispatch_slack/exceptions.py diff --git a/src/dispatch/plugins/dispatch_slack/exceptions.py b/src/dispatch/plugins/dispatch_slack/exceptions.py new file mode 100644 index 000000000000..1a34d2a054d5 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/exceptions.py @@ -0,0 +1,11 @@ +from dispatch.exceptions import DispatchException + + +class ContextError(DispatchException): + code = "context" + msg_template = "{msg}" + + +class RoleError(DispatchException): + code = "role" + msg_template = "{msg}" From 34b93ea7f82e4e882e22929c1171c5e6efdb69f2 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Wed, 14 Dec 2022 13:44:42 -0800 Subject: [PATCH 06/40] Formatting messages --- src/dispatch/plugins/dispatch_slack/bolt.py | 9 +++- .../dispatch_slack/incident/interactive.py | 53 ++++++++++++------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index bcbb106b3977..aae6f009982e 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,4 +1,5 @@ import logging +from blockkit import Modal from slack_bolt.app.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from slack_bolt.response import BoltResponse @@ -17,7 +18,7 @@ @app.error -async def errors(error, payload, client, respond, logger): +async def errors(ack, error, body, respond, logger): print(error) @@ -28,7 +29,11 @@ async def errors(error, payload, client, respond, logger): elif isinstance(error, RoleError): message = str(error) - await respond(text=message, response_type="ephemeral") + if body.get("view"): + pass + + else: + await respond(text=message, response_type="ephemeral") logger.exception(error) logger.debug(error) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 9de1a29d0863..0ba8727e6d39 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -177,6 +177,9 @@ def configure(config): 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) @@ -189,9 +192,6 @@ def configure(config): app.command(config.slack_command_update_participant, middleware=middleware)( handle_update_participant_command ) - app.command(config.slack_command_report_incident, middleware=middleware)( - handle_report_incident_command - ) app.command(config.slack_command_list_resources, middleware=middleware)( handle_list_resources_command ) @@ -561,42 +561,41 @@ async def handle_list_resources_command(ack, respond, db_session, context): blocks = [ Section(text=f"*<{incident.title}|https://google.com>*"), Section(text=f"*Description* \n {incident_description}"), - Section( - text=f"*Commander* \n <{incident.commander.individual.weblink}|{incident.commander.individual.name}>" - ), - Section( - text=f"*Reporter* \n <{incident.reporter.individual.weblink}|{incident.reporter.individual.name}>" - ), + ] + + fields = [ + f"*Commander* \n <{incident.commander.individual.weblink}|{incident.commander.individual.name}>", + f"*Reporter* \n <{incident.reporter.individual.weblink}|{incident.reporter.individual.name}>", ] if resolve_attr(incident, "incident_document.weblink"): - blocks.append(Section(text=f"*<{incident.incident_document.weblink}|Incident Document>*")) + fields.append(f"*Incident Document* \n <{incident.incident_document.weblink}|Link>") if resolve_attr(incident, "storage.weblink"): - blocks.append(Section(text=f"*<{incident.storage.weblink}|Storage>*")) + fields.append(f"*Storage* \n <{incident.storage.weblink}|Link>") if resolve_attr(incident, "ticket.weblink"): - blocks.append(Section(text=f"*<{incident.ticket.weblink}|Ticket>*")) + fields.append(f"*Ticket* \n <{incident.ticket.weblink}|Link>") if resolve_attr(incident, "conference.weblink"): - blocks.append(Section(text=f"*<{incident.conference.weblink}|Conference>*")) + fields.append(f"*Conference* \n <{incident.conference.weblink}|Link>") if resolve_attr(incident, "incident_review_document"): - blocks.append( - Section(text=f"*<{incident.incident_review_document}|Incident Review Document>(") - ) + fields.append(f"*Incident Review Document* \n <{incident.incident_review_document}|Link>") faq_doc = document_service.get_incident_faq_document( db_session=db_session, project_id=incident.project_id ) if faq_doc: - blocks.append(Section(text=f"*<{faq_doc.weblink}|FAQ Document>*")) + fields.append(f"*FAQ Document* \n <{faq_doc.weblink}|Link>") conversation_reference = document_service.get_conversation_reference_document( db_session=db_session, project_id=incident.project_id ) if conversation_reference: - blocks.append(Section(text=f"*<{conversation_reference.weblink}|Command Reference>*")) + fields.append(f"*Command Reference* \n <{conversation_reference.weblink}|Link>") + + blocks.append(Section(fields=fields)) blocks = Message(blocks=blocks).build()["blocks"] await respond(text="Incident Resources Message", blocks=blocks, response_type="ephemeral") @@ -1598,11 +1597,25 @@ async def handle_report_incident_submission_event(ack, user, client, body, db_se 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={"name": form_data[DefaultBlockIds.incident_type_select]["name"]}, - incident_priority={"name": form_data[DefaultBlockIds.incident_priority_select]["name"]}, + incident_type=incident_type, + incident_priority=incident_priority, + incident_severity=incident_severity, project=project, reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), tags=tags, From d5cd4c6dd9a3159802bf8b0d15d6ebf866366128 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Fri, 16 Dec 2022 10:15:24 -0800 Subject: [PATCH 07/40] Modal submission --- src/dispatch/plugins/dispatch_slack/bolt.py | 5 +- .../dispatch_slack/incident/interactive.py | 238 ++++++++++++------ .../plugins/dispatch_slack/messaging.py | 51 ++-- src/dispatch/plugins/dispatch_slack/plugin.py | 21 +- 4 files changed, 202 insertions(+), 113 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index aae6f009982e..c9fa6e1d3ef1 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,5 +1,4 @@ import logging -from blockkit import Modal from slack_bolt.app.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from slack_bolt.response import BoltResponse @@ -35,6 +34,10 @@ async def errors(ack, error, body, respond, logger): else: await respond(text=message, response_type="ephemeral") + logger.debug(body) + from pprint import pprint + + pprint(body) logger.exception(error) logger.debug(error) return BoltResponse(status=200, body="") diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 0ba8727e6d39..73c14e4d637a 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -23,6 +23,7 @@ from sqlalchemy import func from dispatch.config import DISPATCH_UI_URL +from dispatch.messaging.strings import INCIDENT_RESOURCES_MESSAGE from dispatch.database.core import resolve_attr from dispatch.database.service import search_filter_sort_paginate from dispatch.document import service as document_service @@ -98,6 +99,11 @@ get_user_email_async, get_user_profile_by_email_async, ) +from dispatch.plugins.dispatch_slack.messaging import create_message_blocks +from dispatch.plugins.dispatch_slack.service import chunks + +from dispatch.messaging.strings import MessageType + from dispatch.project import service as project_service from dispatch.report import flows as report_flows from dispatch.report import service as report_service @@ -110,6 +116,7 @@ from dispatch.task.enums import TaskStatus from dispatch.task.models import Task + log = logging.getLogger(__file__) @@ -156,6 +163,100 @@ async def dispatch(self, *args, **kwargs): message_dispatcher = MessageDispatcher() +async def ack_shortcut(ack): + await ack() + + +async def open_modal(body, client): + await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "title": { + "type": "plain_text", + "text": "Socket Modal", + }, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + +all_options = [ + { + "text": {"type": "plain_text", "text": ":cat: Cat"}, + "value": "cat", + }, + { + "text": {"type": "plain_text", "text": ":dog: Dog"}, + "value": "dog", + }, + { + "text": {"type": "plain_text", "text": ":bear: Bear"}, + "value": "bear", + }, +] + + +@app.options("favorite-animal") +async def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + await ack(options=options) + else: + await ack(options=all_options) + + +async def submission(): + import time + + time.sleep(10) + + +app.view("socket_modal_submission")(ack=ack_shortcut, lazy=[submission]) + + @app.event( {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] ) @@ -399,20 +500,21 @@ async def handle_list_incidents_command(ack, payload, respond, db_session, conte blocks.append( Section( fields=[ - f"*Title*:\n {incident.title}", - f"*Type*:\n {incident.incident_type.name}", - f"*Severity*:\n {incident.incident_severity.name}", - f"*Priority*:\n {incident.incident_priority.name}", - f"*Status*:\n{incident.status}", - f"*Incident Commander*:\n<{incident.commander.individual.weblink}|{incident.commander.individual.name}>", - f"*Project*:\n{incident.project.name}", + 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()) - blocks = Message(blocks=blocks).build()["blocks"] - await respond(text="Incident List", blocks=blocks, response_type="ephemeral") + 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, respond, client, db_session, context): @@ -550,6 +652,7 @@ async def handle_list_tasks_command(ack, user, respond, context, db_session): async def handle_list_resources_command(ack, respond, db_session, context): """Handles the list resources command.""" await ack() + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) incident_description = ( @@ -558,47 +661,41 @@ async def handle_list_resources_command(ack, respond, db_session, context): else f"{incident.description[:500]}..." ) - blocks = [ - Section(text=f"*<{incident.title}|https://google.com>*"), - Section(text=f"*Description* \n {incident_description}"), - ] - - fields = [ - f"*Commander* \n <{incident.commander.individual.weblink}|{incident.commander.individual.name}>", - f"*Reporter* \n <{incident.reporter.individual.weblink}|{incident.reporter.individual.name}>", - ] - - if resolve_attr(incident, "incident_document.weblink"): - fields.append(f"*Incident Document* \n <{incident.incident_document.weblink}|Link>") - - if resolve_attr(incident, "storage.weblink"): - fields.append(f"*Storage* \n <{incident.storage.weblink}|Link>") - - if resolve_attr(incident, "ticket.weblink"): - fields.append(f"*Ticket* \n <{incident.ticket.weblink}|Link>") - - if resolve_attr(incident, "conference.weblink"): - fields.append(f"*Conference* \n <{incident.conference.weblink}|Link>") - - if resolve_attr(incident, "incident_review_document"): - fields.append(f"*Incident Review Document* \n <{incident.incident_review_document}|Link>") + # 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: - fields.append(f"*FAQ Document* \n <{faq_doc.weblink}|Link>") + 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: - fields.append(f"*Command Reference* \n <{conversation_reference.weblink}|Link>") - - blocks.append(Section(fields=fields)) + 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 Message", blocks=blocks, response_type="ephemeral") + await respond(text="Incident Resources", blocks=blocks, response_type="ephemeral") # EVENTS @@ -1193,8 +1290,6 @@ async def handle_engage_oncall_command(ack, respond, context, body, client, db_s ) async def handle_engage_oncall_submission_event(ack, user, context, db_session, form_data): """Handles the engage oncall submission""" - await ack() - oncall_service_external_id = form_data[EngageOncallBlockIds.service]["value"] page = form_data.get(EngageOncallBlockIds.page, {"value": None})["value"] @@ -1369,29 +1464,15 @@ async def handle_report_executive_command(ack, body, client, respond, context, d ReportExecutiveActions.submit, middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], ) -async def handle_report_executive_submission_event( - user, client, body, context, db_session, form_data -): +async def handle_report_executive_submission_event(ack, user, context, db_session, form_data): """Handles the report executive submission""" + await ack(response_type="close") executive_report_in = ExecutiveReportCreate( current_status=form_data[ReportExecutiveBlockIds.current_status], overview=form_data[ReportExecutiveBlockIds.overview], next_steps=form_data[ReportExecutiveBlockIds.next_steps], ) - modal = Modal( - title="Executive Report", - close="Close", - blocks=[Section(text="Creating report and sending it to recipients...")], - ).build() - - stack = await client.views_update( - view_id=body["view"]["id"], - hash=body["view"]["hash"], - trigger_id=body["trigger_id"], - view=modal, - ) - report_flows.create_executive_report( user_email=user.email, incident_id=context["subject"].id, @@ -1399,19 +1480,6 @@ async def handle_report_executive_submission_event( db_session=db_session, ) - modal = Modal( - title="Executive Report", - close="Close", - blocks=[Section(text="Creating report and sending it to recipients... Success!")], - ).build() - - await client.views_update( - view_id=stack["view"]["id"], - hash=stack["view"]["hash"], - trigger_id=stack["trigger_id"], - view=modal, - ) - async def handle_update_incident_command(ack, body, client, context, db_session): """Creates the incident update modal.""" @@ -1477,7 +1545,7 @@ async def handle_update_incident_command(ack, body, client, context, db_session) middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], ) async def handle_update_incident_submission_event( - ack, body, client, user, context, db_session, form_data + body, client, user, context, db_session, form_data ): """Handles the update incident submission""" incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) @@ -1589,7 +1657,7 @@ async def handle_report_incident_command(ack, body, context, db_session, client) ) async def handle_report_incident_submission_event(ack, user, client, body, db_session, form_data): """Handles the report incident submission""" - await ack() + tags = [] for t in form_data.get(DefaultBlockIds.tags_multi_select, []): # we have to fetch as only the IDs are embedded in slack @@ -1621,6 +1689,19 @@ async def handle_report_incident_submission_event(ack, user, client, body, db_se 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"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + # Create the incident incident = incident_service.create(db_session=db_session, incident_in=incident_in) @@ -1628,24 +1709,25 @@ async def handle_report_incident_submission_event(ack, user, client, body, db_se Section( text="This is a confirmation that you have reported a incident with the following information. You will be invited to an incident slack conversation shortly." ), - Section(text=f"*Incident Title*\n {incident.title}"), + Section(text=f"*Title*\n {incident.title}"), Section(text=f"*Description*\n {incident.description}"), Section( fields=[ - MarkdownText(text=f"*Commander* \n {incident.commander.individual.name}"), + 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() - await client.views_update( - view_id=body["view"]["id"], - hash=body["view"]["hash"], - trigger_id=body["trigger_id"], + result = await client.views_update( + view_id=result["view"]["id"], + hash=result["view"]["hash"], + trigger_id=result["trigger_id"], view=modal, ) diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index a85d9f53ba71..79eb8f18e338 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -7,6 +7,8 @@ import logging from typing import List, Optional +from blockkit import Section, Divider, Button, Context, MarkdownText, PlainText, Actions + from dispatch.messaging.strings import ( EVERGREEN_REMINDER_DESCRIPTION, INCIDENT_PARTICIPANT_SUGGESTED_READING_DESCRIPTION, @@ -53,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) @@ -63,38 +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"): - element = { - "action_id": button["button_action"], - "type": "button", - "text": {"type": "plain_text", "text": button["button_text"]}, - "value": button["button_value"], - } - if button.get("button_url"): - element.update({"url": button["button_url"]}) - - block["elements"].append(element) - - blocks.append(block) + 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 @@ -116,7 +113,7 @@ 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: if message_template: diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 0ac980f0be36..b8b1ce2416a8 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -5,17 +5,19 @@ :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, @@ -45,7 +47,6 @@ unarchive_conversation, ) - logger = logging.getLogger(__name__) @@ -83,7 +84,9 @@ 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): @@ -105,7 +108,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) @@ -125,7 +130,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()["block"] archived = conversation_archived(client, conversation_id) if not archived: From 25bbda195d729ce8ab04522c20eeb38f9da87224 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Fri, 16 Dec 2022 14:39:34 -0800 Subject: [PATCH 08/40] More experiments --- src/dispatch/plugins/dispatch_slack/bolt.py | 4 +++- .../dispatch_slack/incident/interactive.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index c9fa6e1d3ef1..3fa3592d5093 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -10,7 +10,9 @@ from .exceptions import ContextError, RoleError -app = AsyncApp(token="xoxb-valid", raise_error_for_unhandled_request=True) +app = AsyncApp( + token="xoxb-valid", raise_error_for_unhandled_request=True, process_before_response=True +) router = APIRouter() logging.basicConfig(level=logging.DEBUG) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 73c14e4d637a..cc0577a683bc 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -248,11 +248,26 @@ async def external_data_source_handler(ack, body): await ack(options=all_options) -async def submission(): +async def submission(body, client): import time + raise Exception + time.sleep(10) + modal = Modal( + title="Incident Update", + close="Close", + blocks=[Section(text="The incident is being updated...")], + ).build() + + await client.views_update( + view_id=body["view"]["id"], + hash=body["view"]["hash"], + trigger_id=body["trigger_id"], + view=modal, + ) + app.view("socket_modal_submission")(ack=ack_shortcut, lazy=[submission]) From 7471853389a763fbf516e113a90fb835ae934ef2 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Mon, 19 Dec 2022 16:06:44 -0800 Subject: [PATCH 09/40] Making modal success messages more straight forward --- src/dispatch/plugins/dispatch_slack/bolt.py | 44 +- .../plugins/dispatch_slack/exceptions.py | 10 + src/dispatch/plugins/dispatch_slack/fields.py | 2 +- .../dispatch_slack/incident/interactive.py | 458 +++++++++--------- 4 files changed, 252 insertions(+), 262 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 3fa3592d5093..520320c8b2de 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,14 +1,15 @@ import logging +from blockkit import Section, Modal from slack_bolt.app.async_app import AsyncApp -from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from slack_bolt.response import BoltResponse - +from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from fastapi import APIRouter from starlette.requests import Request -from .exceptions import ContextError, RoleError +from .decorators import message_dispatcher +from .middleware import message_context_middleware, db_middleware, user_middleware app = AsyncApp( token="xoxb-valid", raise_error_for_unhandled_request=True, process_before_response=True @@ -19,30 +20,27 @@ @app.error -async def errors(ack, error, body, respond, logger): - - print(error) +async def app_error_handler(error, client, body, logger): + modal = Modal( + title="Error", close="Close", blocks=[Section(text="Something went wrong...")] + ).build() - message = "An unknown error has occured." - if isinstance(error, ContextError): - message = str(error) + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) - elif isinstance(error, RoleError): - message = str(error) + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") + return BoltResponse(body="", code=200) - if body.get("view"): - pass - else: - await respond(text=message, response_type="ephemeral") - - logger.debug(body) - from pprint import pprint - - pprint(body) - logger.exception(error) - logger.debug(error) - return BoltResponse(status=200, body="") +@app.event( + {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] +) +async def handle_message_events(ack, payload, context, body, client, respond, user, db_session): + """Container function for all message functions.""" + await message_dispatcher.dispatch(**locals()) handler = AsyncSlackRequestHandler(app) diff --git a/src/dispatch/plugins/dispatch_slack/exceptions.py b/src/dispatch/plugins/dispatch_slack/exceptions.py index 1a34d2a054d5..31165426db1b 100644 --- a/src/dispatch/plugins/dispatch_slack/exceptions.py +++ b/src/dispatch/plugins/dispatch_slack/exceptions.py @@ -1,6 +1,16 @@ 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}" diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index c86ebc8c81d7..c0823fc60c94 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -503,7 +503,7 @@ def participant_select( **kwargs, ): """Creates a static select of available participants.""" - participants = [{"text": p.individual.name, "value": p.individual.id} for p in participants] + participants = [{"text": p.individual.name, "value": p.id} for p in participants] return static_select_block( placeholder="Select Participant", options=participants, diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index cc0577a683bc..50824b8dfc6b 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import List -import inspect import pytz from blockkit import ( Actions, @@ -23,8 +22,7 @@ from sqlalchemy import func from dispatch.config import DISPATCH_UI_URL -from dispatch.messaging.strings import INCIDENT_RESOURCES_MESSAGE -from dispatch.database.core import resolve_attr +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 @@ -35,6 +33,7 @@ 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 @@ -44,6 +43,7 @@ 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, @@ -84,26 +84,22 @@ 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_context_middleware, db_middleware, - message_context_middleware, modal_submit_middleware, - user_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.plugins.dispatch_slack.messaging import create_message_blocks -from dispatch.plugins.dispatch_slack.service import chunks - -from dispatch.messaging.strings import MessageType - from dispatch.project import service as project_service from dispatch.report import flows as report_flows from dispatch.report import service as report_service @@ -116,7 +112,6 @@ from dispatch.task.enums import TaskStatus from dispatch.task.models import Task - log = logging.getLogger(__file__) @@ -128,158 +123,6 @@ class MonitorMetadata(SubjectMetadata): weblink: str -class MessageDispatcher: - """Dispatches current message to any registered function.""" - - registered_funcs = [] - - def add(self, *args, **kwargs): - """Adds a function to the dispatcher.""" - - def decorator(func): - if not kwargs.get("name"): - name = func.__name__ - else: - name = kwargs.pop("name") - - self.registered_funcs.append({"name": name, "func": func}) - - return decorator - - 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) - - try: - await f["func"](*injected_args) - except Exception as e: - log.exception(e) - log.debug(f"Failed to run dispatched function ({e})") - - -message_dispatcher = MessageDispatcher() - - -async def ack_shortcut(ack): - await ack() - - -async def open_modal(body, client): - await client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "modal", - "callback_id": "socket_modal_submission", - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "close": { - "type": "plain_text", - "text": "Cancel", - }, - "title": { - "type": "plain_text", - "text": "Socket Modal", - }, - "blocks": [ - { - "type": "input", - "block_id": "q1", - "label": { - "type": "plain_text", - "text": "Write anything here!", - }, - "element": { - "action_id": "feedback", - "type": "plain_text_input", - }, - }, - { - "type": "input", - "block_id": "q2", - "label": { - "type": "plain_text", - "text": "Can you tell us your favorites?", - }, - "element": { - "type": "external_select", - "action_id": "favorite-animal", - "min_query_length": 0, - "placeholder": { - "type": "plain_text", - "text": "Select your favorites", - }, - }, - }, - ], - }, - ) - - -app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) - -all_options = [ - { - "text": {"type": "plain_text", "text": ":cat: Cat"}, - "value": "cat", - }, - { - "text": {"type": "plain_text", "text": ":dog: Dog"}, - "value": "dog", - }, - { - "text": {"type": "plain_text", "text": ":bear: Bear"}, - "value": "bear", - }, -] - - -@app.options("favorite-animal") -async def external_data_source_handler(ack, body): - keyword = body.get("value") - if keyword is not None and len(keyword) > 0: - options = [o for o in all_options if keyword in o["text"]["text"]] - await ack(options=options) - else: - await ack(options=all_options) - - -async def submission(body, client): - import time - - raise Exception - - time.sleep(10) - - modal = Modal( - title="Incident Update", - close="Close", - blocks=[Section(text="The incident is being updated...")], - ).build() - - await client.views_update( - view_id=body["view"]["id"], - hash=body["view"]["hash"], - trigger_id=body["trigger_id"], - view=modal, - ) - - -app.view("socket_modal_submission")(ack=ack_shortcut, lazy=[submission]) - - -@app.event( - {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] -) -async def handle_message_events(ack, payload, context, body, client, respond, user, db_session): - """Container function for all message functions.""" - await message_dispatcher.dispatch(**locals()) - - def configure(config): """Maps commands/events to their functions.""" middleware = [ @@ -1012,12 +855,24 @@ async def handle_add_timeline_event_command(ack, body, client, context): ) -@app.view( - AddTimelineEventActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) -async def handle_add_timeline_submission_event(ack, user, client, context, db_session, form_data): +async def ack_add_timeline_submission_event(ack): + """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, user, client, context, form_data): """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{context['subject'].organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() 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"] @@ -1046,16 +901,22 @@ async def handle_add_timeline_submission_event(ack, user, client, context, db_se individual_id=participant.individual.id, ) - blocks = [Section(text="Success!")] - modal = Modal( - title="Timeline Event Added", + title="Add Timeline Event", close="Close", - blocks=blocks, - private_metadata=context["subject"].json(), + blocks=[Section(text="Adding timeline event... Success!")], ).build() - await ack(response_action="update", view=modal) + 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, respond, body, context, db_session, client): @@ -1097,15 +958,49 @@ async def handle_update_participant_command(ack, respond, body, context, db_sess await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( +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, client, context, db_session, form_data): + """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{context['subject'].organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() + 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], -) -async def handle_update_participant_submission_event( - ack, user, client, context, db_session, form_data -): - """Handles the update participant submission event.""" - ack() +)(ack=ack_update_participant_submission_event, lazy=[handle_update_participant_submission_event]) async def handle_update_notifications_group_command(ack, body, context, client, db_session): @@ -1155,10 +1050,16 @@ async def handle_update_notifications_group_command(ack, body, context, client, await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - UpdateNotificationGroupActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) +async def ack_update_notifications_group_submission_event(ack): + """Handles the update notifications group submission acknowledgement.""" + modal = Modal( + title="Update Notifications Group", + close="Close", + blocks=[Section(text="Updating notifications group...")], + ).build() + await ack(response_action="update", view=modal) + + async def handle_update_notifications_group_submission_event( ack, user, client, context, db_session, form_data ): @@ -1166,6 +1067,15 @@ async def handle_update_notifications_group_submission_event( ack() +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, context, body, client): """Handles the assign role command.""" await ack() @@ -1205,12 +1115,24 @@ async def handle_assign_role_command(ack, context, body, client): await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - AssignRoleActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) -async def handle_assign_role_submission_event(ack, user, client, context, db_session, form_data): +async def ack_assign_role_submission_event(ack): + """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, user, client, context, form_data): """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{context['subject'].organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() 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) @@ -1230,11 +1152,19 @@ async def handle_assign_role_submission_event(ack, user, client, context, db_ses ): # we update the external ticket incident_flows.update_external_incident_ticket( - incident_id=context["subject"].id, db_session=db_session + incident_id=context["subject"].id, db_session=context["subject"].organization_slug ) - modal = Modal(title="Engagement", blocks=[Section(text="Success!")], close="Close").build() - await ack(response_action="update", view=modal) + 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, respond, context, body, client, db_session): @@ -1388,16 +1318,16 @@ async def handle_report_tactical_command(ack, client, respond, context, db_sessi await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - ReportTacticalActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) -async def handle_report_tactical_submission_event( - ack, user, client, context, db_session, form_data -): - """Handles the report tactical submission""" - await ack() +async def ack_report_tactical_submission_event(ack): + """Handles report tactical submission event.""" + 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(user, context, form_data): + """Handles the report tactical submission""" tactical_report_in = TacticalReportCreate( conditions=form_data[ReportTacticalBlockIds.conditions], actions=form_data[ReportTacticalBlockIds.actions], @@ -1408,10 +1338,16 @@ async def handle_report_tactical_submission_event( user_email=user.email, incident_id=context["subject"].id, tactical_report_in=tactical_report_in, - db_session=db_session, + db_session=context["subject"].organization_slug, ) +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, body, client, respond, context, db_session): """Handles executive report command.""" await ack() @@ -1475,13 +1411,18 @@ async def handle_report_executive_command(ack, body, client, respond, context, d await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - ReportExecutiveActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) -async def handle_report_executive_submission_event(ack, user, context, db_session, form_data): +async def ack_report_executive_submission_event(ack): + """Handles executive submission acknowledgement.""" + modal = Modal( + title="Executive Report", + close="Close", + blocks=[Section(text="Sending executive report...")], + ).build() + await ack(response_action="update", view=modal) + + +async def handle_report_executive_submission_event(client, body, user, context, form_data): """Handles the report executive submission""" - await ack(response_type="close") executive_report_in = ExecutiveReportCreate( current_status=form_data[ReportExecutiveBlockIds.current_status], overview=form_data[ReportExecutiveBlockIds.overview], @@ -1492,8 +1433,24 @@ async def handle_report_executive_submission_event(ack, user, context, db_sessio user_email=user.email, incident_id=context["subject"].id, executive_report_in=executive_report_in, - db_session=db_session, + organization_slug=context["subject"].organization_slug, ) + modal = Modal( + title="Executive Report", + blocks=[Section(text="Sending 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], +)(ack=ack_report_executive_submission_event, lazy=[handle_report_executive_submission_event]) async def handle_update_incident_command(ack, body, client, context, db_session): @@ -1555,14 +1512,28 @@ async def handle_update_incident_command(ack, body, client, context, db_session) await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - IncidentUpdateActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) +async def ack_incident_update_submission_event(ack): + """Handles incident update submission event.""" + modal = Modal( + title="Incident Update", + close="Close", + blocks=[Section(text="The incident is being updated...")], + ).build() + await ack(response_action="update", view=modal) + + async def handle_update_incident_submission_event( body, client, user, context, db_session, form_data ): """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{context['subject'].organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) tags = [] @@ -1595,19 +1566,6 @@ async def handle_update_incident_submission_event( db_session=db_session, incident=incident, incident_in=incident_in ) - modal = Modal( - title="Incident Update", - close="Close", - blocks=[Section(text="The incident is being updated...")], - ).build() - - stack = await client.views_update( - view_id=body["view"]["id"], - hash=body["view"]["hash"], - trigger_id=body["trigger_id"], - view=modal, - ) - commander_email = updated_incident.commander.individual.email reporter_email = updated_incident.reporter.individual.email @@ -1619,24 +1577,26 @@ async def handle_update_incident_submission_event( previous_incident, db_session=db_session, ) - modal = Modal( title="Incident Update", close="Close", - blocks=[Section(text="The incident has been successfully updated!")], + blocks=[Section(text="The incident is being updated...success!")], ).build() await client.views_update( - view_id=stack["view"]["id"], - hash=stack["view"]["hash"], - trigger_id=stack["trigger_id"], + 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, body, context, db_session, client): """Handles the report incident command.""" - await ack() blocks = [ Context( elements=[ @@ -1666,12 +1626,28 @@ async def handle_report_incident_command(ack, body, context, db_session, client) await client.views_open(trigger_id=body["trigger_id"], view=modal) -@app.view( - IncidentReportActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], -) -async def handle_report_incident_submission_event(ack, user, client, body, db_session, form_data): +async def ack_report_incident_submission_event(ack): + """Handles the report incident submission event acknowledgment.""" + modal = Modal( + title="Report Incident", + close="Close", + blocks=[Section(text="Creating incident resources...")], + ).build() + ack(response_action="update", view=modal) + + +async def handle_report_incident_submission_event(user, client, body, form_data): """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: "dispatch_organization_default", + } + ) + db_session = sessionmaker(bind=schema_engine)() tags = [] for t in form_data.get(DefaultBlockIds.tags_multi_select, []): @@ -1753,6 +1729,12 @@ async def handle_report_incident_submission_event(ack, user, client, body, db_se ) +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] ) From 1bd965dcf636080793792cbede00b79a00ef6389 Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Mon, 19 Dec 2022 16:07:03 -0800 Subject: [PATCH 10/40] Adding a decorator module --- .../plugins/dispatch_slack/decorators.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/dispatch/plugins/dispatch_slack/decorators.py diff --git a/src/dispatch/plugins/dispatch_slack/decorators.py b/src/dispatch/plugins/dispatch_slack/decorators.py new file mode 100644 index 000000000000..cc7e753deaa1 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/decorators.py @@ -0,0 +1,39 @@ +import logging +import inspect + +log = logging.getLogger(__file__) + + +class MessageDispatcher: + """Dispatches current message to any registered function.""" + + registered_funcs = [] + + def add(self, *args, **kwargs): + """Adds a function to the dispatcher.""" + + def decorator(func): + if not kwargs.get("name"): + name = func.__name__ + else: + name = kwargs.pop("name") + + self.registered_funcs.append({"name": name, "func": func}) + + return decorator + + 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) + + try: + await f["func"](*injected_args) + except Exception as e: + log.exception(e) + log.debug(f"Failed to run dispatched function ({e})") + + +message_dispatcher = MessageDispatcher() From a33e1b3e35e40a7989a68c94ce0d60a47e3debfc Mon Sep 17 00:00:00 2001 From: Kevin Glisson Date: Tue, 20 Dec 2022 09:07:43 -0800 Subject: [PATCH 11/40] Allowing unregistered users --- src/dispatch/plugins/dispatch_slack/middleware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py index 38f6826dbf6f..da93dfd755c4 100644 --- a/src/dispatch/plugins/dispatch_slack/middleware.py +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -3,7 +3,7 @@ from sqlalchemy.orm.session import Session from dispatch.auth import service as user_service -from dispatch.auth.models import DispatchUser +from dispatch.auth.models import UserRegister from dispatch.conversation import service as conversation_service from dispatch.conversation.models import Conversation from dispatch.database.core import SessionLocal, engine, sessionmaker @@ -122,7 +122,7 @@ async def user_middleware(body, payload, db_session, client, context, next): context["user"] = user_service.get_or_create( db_session=db_session, organization=context["subject"].organization_slug, - user_in=DispatchUser(email=email), + user_in=UserRegister(email=email), ) await next() From 75d858d693a52958e20a6c5e1e0706fa1cde1ac9 Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Tue, 20 Dec 2022 13:33:47 -0800 Subject: [PATCH 12/40] Resolve KeyError when sending ephemeral message --- src/dispatch/plugins/dispatch_slack/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index b8b1ce2416a8..ce2816effca5 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -132,7 +132,7 @@ def send_ephemeral( if not blocks: blocks = Message( blocks=create_message_blocks(message_template, notification_type, items, **kwargs) - ).build()["block"] + ).build()["blocks"] archived = conversation_archived(client, conversation_id) if not archived: From f30ef0e87c544b6d93711de29cada02a78a46acf Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Tue, 20 Dec 2022 14:22:49 -0800 Subject: [PATCH 13/40] Resolves unexpected keyword argument 'code' in BoltResponse TypeError: __init__() got an unexpected keyword argument 'code' https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py#L7 --- src/dispatch/plugins/dispatch_slack/bolt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 520320c8b2de..afdd8d49e5c1 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -32,7 +32,7 @@ async def app_error_handler(error, client, body, logger): logger.exception(f"Error: {error}") logger.info(f"Request body: {body}") - return BoltResponse(body="", code=200) + return BoltResponse(body="", status=200) @app.event( From 44626164cbf6b16a1d7af846867b8c5f5761eaa8 Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:03:40 -0800 Subject: [PATCH 14/40] Resolves async ack never awaited error --- src/dispatch/plugins/dispatch_slack/incident/interactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 50824b8dfc6b..9b3e41e162fb 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1633,7 +1633,7 @@ async def ack_report_incident_submission_event(ack): close="Close", blocks=[Section(text="Creating incident resources...")], ).build() - ack(response_action="update", view=modal) + await ack(response_action="update", view=modal) async def handle_report_incident_submission_event(user, client, body, form_data): From 2b3b7dd97ce231af78a78adb64d0593e616b95f0 Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:20:55 -0800 Subject: [PATCH 15/40] Enhancement/bolt bookmarks (#2784) * branch off bolt pr and add bookmarks to conversation * branch off bolt pr and add bookmarks to conversation * add types and docstring to set_conversation_bookmark * use existing slack_client functionality instead of bolt to call bookmark.add api * remove deleted bolt function from imports Co-authored-by: Will Sheldon --- src/dispatch/incident/flows.py | 126 +++++++++++++----- src/dispatch/plugins/dispatch_slack/plugin.py | 6 + .../plugins/dispatch_slack/service.py | 12 ++ 3 files changed, 108 insertions(+), 36 deletions(-) diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 456ead743b19..daff9a27cef7 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -542,6 +542,58 @@ 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" + ) + 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 ): @@ -823,6 +875,42 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session ) log.exception(e) + # we update the incident ticket + update_external_incident_ticket(incident.id, db_session) + + # we update the investigation document + document_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="document" + ) + if document_plugin: + if incident.incident_document: + try: + document_plugin.instance.update( + incident.incident_document.resource_id, + commander_fullname=incident.commander.individual.name, + conference_challenge=resolve_attr(incident, "conference.challenge"), + conference_weblink=resolve_attr(incident, "conference.weblink"), + conversation_weblink=resolve_attr(incident, "conversation.weblink"), + description=incident.description, + document_weblink=resolve_attr(incident, "incident_document.weblink"), + name=incident.name, + priority=incident.incident_priority.name, + severity=incident.incident_severity.name, + status=incident.status, + storage_weblink=resolve_attr(incident, "storage.weblink"), + ticket_weblink=resolve_attr(incident, "ticket.weblink"), + title=incident.title, + type=incident.incident_type.name, + ) + except Exception as e: + event_service.log_incident_event( + db_session=db_session, + source="Dispatch Core App", + description=f"Incident documents rendering failed. Reason: {e}", + incident_id=incident.id, + ) + log.exception(e) + # we create the conversation for real-time communications conversation_plugin = plugin_service.get_active_instance( @@ -851,6 +939,8 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session # we set the conversation topic set_conversation_topic(incident, db_session) + # we set the conversation bookmarks + set_conversation_bookmarks(incident, db_session) except Exception as e: event_service.log_incident_event( db_session=db_session, @@ -860,42 +950,6 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session ) log.exception(e) - # we update the incident ticket - update_external_incident_ticket(incident.id, db_session) - - # we update the investigation document - document_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="document" - ) - if document_plugin: - if incident.incident_document: - try: - document_plugin.instance.update( - incident.incident_document.resource_id, - commander_fullname=incident.commander.individual.name, - conference_challenge=resolve_attr(incident, "conference.challenge"), - conference_weblink=resolve_attr(incident, "conference.weblink"), - conversation_weblink=resolve_attr(incident, "conversation.weblink"), - description=incident.description, - document_weblink=resolve_attr(incident, "incident_document.weblink"), - name=incident.name, - priority=incident.incident_priority.name, - severity=incident.incident_severity.name, - status=incident.status, - storage_weblink=resolve_attr(incident, "storage.weblink"), - ticket_weblink=resolve_attr(incident, "ticket.weblink"), - title=incident.title, - type=incident.incident_type.name, - ) - except Exception as e: - event_service.log_incident_event( - db_session=db_session, - source="Dispatch Core App", - description=f"Incident documents rendering failed. Reason: {e}", - incident_id=incident.id, - ) - log.exception(e) - # 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/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index ce2816effca5..637baccfd9ea 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -44,6 +44,7 @@ send_ephemeral_message, send_message, set_conversation_topic, + set_conversation_bookmark, unarchive_conversation, ) @@ -167,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 0303952ce922..731f1a4344b8 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -323,6 +323,18 @@ def set_conversation_topic(client: Any, conversation_id: str, topic: str): return make_call(client, "conversations.setTopic", channel=conversation_id, topic=topic) +def set_conversation_bookmark(client: Any, conversation_id: str, weblink, 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( From 5bf369b2b93c38f66c83652067cc68878227163c Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Thu, 22 Dec 2022 11:57:54 -0800 Subject: [PATCH 16/40] check for existence of body and view in global error handler and ack inc command --- src/dispatch/plugins/dispatch_slack/bolt.py | 9 +++++---- .../plugins/dispatch_slack/incident/interactive.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index afdd8d49e5c1..6f0f0674f99f 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -25,10 +25,11 @@ async def app_error_handler(error, client, body, logger): title="Error", close="Close", blocks=[Section(text="Something went wrong...")] ).build() - await client.views_update( - view_id=body["view"]["id"], - view=modal, - ) + if body and body.get("view"): + await client.views_update( + view_id=body["view"]["id"], + view=modal, + ) logger.exception(f"Error: {error}") logger.info(f"Request body: {body}") diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 9b3e41e162fb..d0eb65f87221 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1596,6 +1596,7 @@ async def handle_update_incident_submission_event( async def handle_report_incident_command(ack, body, context, db_session, client): + await ack() """Handles the report incident command.""" blocks = [ Context( From e8990fa1585133ce147fd782ee1d303bad753d0e Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Thu, 22 Dec 2022 13:19:35 -0800 Subject: [PATCH 17/40] resolves hash conflict in incident report view and resolves UTC event timeline --- src/dispatch/plugins/dispatch_slack/fields.py | 2 +- src/dispatch/plugins/dispatch_slack/incident/interactive.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index c0823fc60c94..038babc91c6d 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -78,7 +78,7 @@ class DefaultActionIds(DispatchEnum): class TimezoneOptions(DispatchEnum): local = "Local Time (based on your slack profile)" - utc = "Coordinated Universal Time (UTC)" + utc = "UTC" def date_picker_input( diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index d0eb65f87221..b46df776bb5b 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1596,8 +1596,9 @@ async def handle_update_incident_submission_event( async def handle_report_incident_command(ack, body, context, db_session, client): - await ack() """Handles the report incident command.""" + await ack() + blocks = [ Context( elements=[ @@ -1689,7 +1690,6 @@ async def handle_report_incident_submission_event(user, client, body, form_data) result = await client.views_update( view_id=body["view"]["id"], - hash=body["view"]["hash"], trigger_id=body["trigger_id"], view=modal, ) From 92573343c7c8fa5d1dbce58138431caea6a87ebf Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Thu, 22 Dec 2022 13:48:47 -0800 Subject: [PATCH 18/40] resolves report-tactical command, update view, and pass org slug --- .../plugins/dispatch_slack/incident/interactive.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index b46df776bb5b..3807c633ba76 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1326,7 +1326,7 @@ async def ack_report_tactical_submission_event(ack): await ack(response_action="update", view=modal) -async def handle_report_tactical_submission_event(user, context, form_data): +async def handle_report_tactical_submission_event(client, body, user, context, form_data): """Handles the report tactical submission""" tactical_report_in = TacticalReportCreate( conditions=form_data[ReportTacticalBlockIds.conditions], @@ -1338,7 +1338,17 @@ async def handle_report_tactical_submission_event(user, context, form_data): user_email=user.email, incident_id=context["subject"].id, tactical_report_in=tactical_report_in, - db_session=context["subject"].organization_slug, + organization_slug=context["subject"].organization_slug, + ) + modal = Modal( + title="Tactical Report", + blocks=[Section(text="Sending tactical report... Success!")], + close="Close", + ).build() + + await client.views_update( + view_id=body["view"]["id"], + view=modal, ) From ea86e09e7d11bca6bdc223162e0cfd3908641ae9 Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Thu, 22 Dec 2022 16:30:27 -0800 Subject: [PATCH 19/40] implements notification group update command --- .../dispatch_slack/incident/interactive.py | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 3807c633ba76..c27f534edc72 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -164,6 +164,9 @@ def configure(config): 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 ) @@ -388,6 +391,12 @@ async def handle_list_participants_command(ack, respond, client, db_session, con 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: @@ -1003,12 +1012,17 @@ async def handle_update_participant_submission_event(body, client, context, db_s )(ack=ack_update_participant_submission_event, lazy=[handle_update_participant_submission_event]) -async def handle_update_notifications_group_command(ack, body, context, client, db_session): +async def handle_update_notifications_group_command( + ack, respond, body, context, client, db_session +): """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) @@ -1016,6 +1030,19 @@ async def handle_update_notifications_group_command(ack, body, context, client, 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 = [ @@ -1029,17 +1056,17 @@ async def handle_update_notifications_group_command(ack, body, context, client, Input( label="Members", element=PlainTextInput( - text=", ".join(members), + initial_value=", ".join(members), multiline=True, action_id=UpdateNotificationGroupActionIds.members, ), block_id=UpdateNotificationGroupBlockIds.members, ), - Context(elements=MarkdownText(text="Separate email addresses with commas")), + Context(elements=[MarkdownText(text="Separate email addresses with commas")]), ] modal = Modal( - title="Update Group Membership", + title="Update Group Members", # 24 Char Limit blocks=blocks, close="Cancel", submit="Submit", @@ -1053,7 +1080,7 @@ async def handle_update_notifications_group_command(ack, body, context, client, async def ack_update_notifications_group_submission_event(ack): """Handles the update notifications group submission acknowledgement.""" modal = Modal( - title="Update Notifications Group", + title="Update Group Members", close="Close", blocks=[Section(text="Updating notifications group...")], ).build() @@ -1061,10 +1088,46 @@ async def ack_update_notifications_group_submission_event(ack): async def handle_update_notifications_group_submission_event( - ack, user, client, context, db_session, form_data + ack, body, user, client, context, db_session, form_data ): - """Handles the update notifications group submission""" - ack() + """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 + schema_engine = engine.execution_options( + schema_translate_map={ + None: f"dispatch_organization_{context['subject'].organization_slug}", + } + ) + db_session = sessionmaker(bind=schema_engine)() + + 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( From 219cfb03e8910bbbcc687e67df46938522931f8e Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Fri, 23 Dec 2022 09:27:54 -0800 Subject: [PATCH 20/40] implement config middleware, functionify refetch_db, and uncomment config specific logic --- src/dispatch/plugins/dispatch_slack/bolt.py | 16 +++- .../dispatch_slack/incident/interactive.py | 96 ++++++++----------- .../plugins/dispatch_slack/middleware.py | 33 +++++-- 3 files changed, 82 insertions(+), 63 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 6f0f0674f99f..14a2917f01e2 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -9,7 +9,13 @@ from starlette.requests import Request from .decorators import message_dispatcher -from .middleware import message_context_middleware, db_middleware, user_middleware +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 @@ -37,7 +43,13 @@ async def app_error_handler(error, client, body, logger): @app.event( - {"type": "message"}, middleware=[message_context_middleware, db_middleware, user_middleware] + {"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): """Container function for all message functions.""" diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index c27f534edc72..031c918487a0 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -20,6 +20,7 @@ UsersSelect, ) from sqlalchemy import func +from sqlalchemy.orm import Session from dispatch.config import DISPATCH_UI_URL from dispatch.database.core import engine, resolve_attr, sessionmaker @@ -88,7 +89,7 @@ from dispatch.plugins.dispatch_slack.middleware import ( action_context_middleware, command_context_middleware, - configuration_context_middleware, + configuration_middleware, db_middleware, modal_submit_middleware, restricted_command_middleware, @@ -128,8 +129,8 @@ def configure(config): middleware = [ command_context_middleware, db_middleware, - configuration_context_middleware, user_middleware, + configuration_middleware, ] # don't need an incident context @@ -183,6 +184,16 @@ def configure(config): ) +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] ) @@ -461,14 +472,14 @@ def filter_tasks_by_assignee_and_creator(tasks: List[Task], by_assignee: str, by return filtered_tasks -async def handle_list_tasks_command(ack, user, respond, context, db_session): +async def handle_list_tasks_command(ack, user, body, respond, context, db_session): """Handles the list tasks command.""" await ack() blocks = [] caller_only = False - # if body["command"] == context["config"].slack_command_list_my_tasks: - # caller_only = True + 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*")) @@ -687,9 +698,8 @@ async def handle_after_hours_message(ack, context, client, payload, user, db_ses @message_dispatcher.add() async def handle_thread_creation(client, payload, context): """Sends the user an ephemeral message if they use threads.""" - # TODO figure out how to pass current slack config - # if not context["config"].ban_threads: - # return + if not context["config"].ban_threads: + return if context["subject"].type == "incident": if payload.get("thread_ts"): @@ -876,12 +886,7 @@ async def handle_add_timeline_submission_event(body, user, client, context, form """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{context['subject'].organization_slug}", - } - ) - db_session = sessionmaker(bind=schema_engine)() + 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"] @@ -979,12 +984,7 @@ async def handle_update_participant_submission_event(body, client, context, db_s """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{context['subject'].organization_slug}", - } - ) - db_session = sessionmaker(bind=schema_engine)() + 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( @@ -1093,12 +1093,7 @@ async def handle_update_notifications_group_submission_event( """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{context['subject'].organization_slug}", - } - ) - db_session = sessionmaker(bind=schema_engine)() + db_session = refetch_db_session(context["subject"].organization_slug) current_members = ( body["view"]["blocks"][1]["element"]["initial_value"].replace(" ", "").split(",") @@ -1190,12 +1185,8 @@ async def handle_assign_role_submission_event(body, user, client, context, form_ """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{context['subject'].organization_slug}", - } - ) - db_session = sessionmaker(bind=schema_engine)() + 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) @@ -1425,7 +1416,7 @@ async def handle_report_executive_command(ack, body, client, respond, context, d """Handles executive report command.""" await ack() - if context["subject"].type == "case": + if context["subject"].type != "incident": await respond( text="Command is not available outside of incident channels.", response_type="ephemeral" ) @@ -1463,13 +1454,13 @@ async def handle_report_executive_command(ack, body, client, respond, context, d ), 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." - # ) - # ] - # ), + Context( + elements=[ + MarkdownText( + text=f"Use {context['config'].slack_command_update_notifications_group} to update the list of recipients of this report." + ) + ] + ), ] modal = Modal( @@ -1522,7 +1513,13 @@ async def handle_report_executive_submission_event(client, body, user, context, app.view( ReportExecutiveActions.submit, - middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], + 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]) @@ -1601,12 +1598,8 @@ async def handle_update_incident_submission_event( """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: f"dispatch_organization_{context['subject'].organization_slug}", - } - ) - db_session = sessionmaker(bind=schema_engine)() + db_session = refetch_db_session(context["subject"].organization_slug) + incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) tags = [] @@ -1711,18 +1704,13 @@ async def ack_report_incident_submission_event(ack): await ack(response_action="update", view=modal) -async def handle_report_incident_submission_event(user, client, body, form_data): +async def handle_report_incident_submission_event(user, context, client, body, form_data): """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 - schema_engine = engine.execution_options( - schema_translate_map={ - None: "dispatch_organization_default", - } - ) - db_session = sessionmaker(bind=schema_engine)() + db_session = refetch_db_session(context["subject"].organization_slug) tags = [] for t in form_data.get(DefaultBlockIds.tags_multi_select, []): diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py index da93dfd755c4..09c11387afd6 100644 --- a/src/dispatch/plugins/dispatch_slack/middleware.py +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -10,6 +10,7 @@ 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 @@ -172,14 +173,8 @@ async def modal_submit_middleware(body, context, next): await next() -# TODO determine how we an get the current slack config -async def configuration_context_middleware(context, db_session, next): - context["config"] = {} # SlackConversationConfiguration() - await next() - - # NOTE we don't need to handle cases because commands are not available in threads. -async def command_context_middleware(context, payload, next): +async def command_context_middleware(context, payload, next, respond): conversation = resolve_conversation_from_context(channel_id=context["channel_id"]) if conversation: context.update( @@ -193,6 +188,10 @@ async def command_context_middleware(context, payload, next): } ) else: + await respond( + text=f"Sorry, I can't determine the correct context to run the command `{payload['command']}`. Are you running this command in an incident channel?", + response_type="ephemeral", + ) 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?" ) @@ -219,3 +218,23 @@ async def db_middleware(context, next): ) context["db_session"] = sessionmaker(bind=schema_engine)() await next() + + +async def configuration_middleware(context, next): + 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() From 52ca4e89c11b992359b3f83a17946fe1966d02da Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Fri, 23 Dec 2022 09:34:59 -0800 Subject: [PATCH 21/40] update configuring-slack docs to include bookmarks.write scope --- docs/admin-guide/administration/plugins/configuring-slack.md | 1 + 1 file changed, 1 insertion(+) 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 From 968dca66e8b593e106d2428d94460fc3295abcfb Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Fri, 23 Dec 2022 11:17:04 -0800 Subject: [PATCH 22/40] minor cleanup of unused args, typechecking, and duplicate strings --- .../plugins/dispatch_slack/incident/interactive.py | 2 +- src/dispatch/plugins/dispatch_slack/middleware.py | 8 +++----- src/dispatch/plugins/dispatch_slack/service.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 031c918487a0..b2f9039475fc 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1088,7 +1088,7 @@ async def ack_update_notifications_group_submission_event(ack): async def handle_update_notifications_group_submission_event( - ack, body, user, client, context, db_session, form_data + body, client, context, db_session, form_data ): """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 diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py index 09c11387afd6..c00300deb7cb 100644 --- a/src/dispatch/plugins/dispatch_slack/middleware.py +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -188,14 +188,12 @@ async def command_context_middleware(context, payload, next, respond): } ) else: + msg = 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 respond( - text=f"Sorry, I can't determine the correct context to run the command `{payload['command']}`. Are you running this command in an incident channel?", + text=msg, response_type="ephemeral", ) - 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?" - ) - + raise ContextError(msg) await next() diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 731f1a4344b8..461155160ed7 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -323,7 +323,7 @@ def set_conversation_topic(client: Any, conversation_id: str, topic: str): return make_call(client, "conversations.setTopic", channel=conversation_id, topic=topic) -def set_conversation_bookmark(client: Any, conversation_id: str, weblink, title: str): +def set_conversation_bookmark(client: Any, conversation_id: str, weblink: str, title: str): """Sets a bookmark for the specified conversation.""" return make_call( client, From 4dc9c296ebe63a3d88bf9e5c08a33320fad4dace Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Fri, 23 Dec 2022 13:14:27 -0800 Subject: [PATCH 23/40] resolve workflow exceptions, blockkit bugs, and front-end UI bugs --- .../plugins/dispatch_slack/workflow.py | 33 ++++++++++--------- .../src/workflow/WorkflowInstanceTab.vue | 10 ++---- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/workflow.py b/src/dispatch/plugins/dispatch_slack/workflow.py index b35b96a4a276..64c99a7ad612 100644 --- a/src/dispatch/plugins/dispatch_slack/workflow.py +++ b/src/dispatch/plugins/dispatch_slack/workflow.py @@ -50,14 +50,13 @@ def configure(config): def workflow_select( db_session: SessionLocal, - project_id: int, 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, project_id=project_id) + workflows = workflow_service.get_enabled(db_session=db_session) return static_select_block( action_id=action_id, @@ -114,8 +113,9 @@ def param_input( return inputs -async def handle_workflow_list_command(ack, body, respond, client, context, db_session): +async def handle_workflow_list_command(ack, respond, context, db_session): """Handles the workflow list command.""" + await ack() incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) workflows = incident.workflow_instances @@ -128,12 +128,14 @@ async def handle_workflow_list_command(ack, body, respond, client, context, db_s blocks.append( Section( fields=[ - f"*Name:* \n <{w.weblink}|{w.workflow.name}>" - f"*Workflow Description:* \n {w.workflow.description}" - f"*Run Reason:* \n {w.run_reason}" - f"*Creator:* \n {w.creator.individual.name}" - f"*Status:* \n {w.status}" - f"*Artifacts:* \n {artifact_links}" + "*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" ] ) ) @@ -151,12 +153,12 @@ async def handle_workflow_run_command( ): """Handles the workflow run command.""" await ack() - incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) blocks = [ Context(elements=[MarkdownText(text="Select a workflow to run.")]), workflow_select( - db_session=db_session, dispatch_action=True, project_id=incident.project.id + db_session=db_session, + dispatch_action=True, ), ] @@ -176,10 +178,11 @@ async def handle_workflow_run_command( RunWorkflowActions.submit, middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware], ) -async def handle_workflow_submission_event(ack, body, client, context, db_session, form_data, user): +async def handle_workflow_submission_event(ack, context, db_session, form_data, user): """Handles workflow submission event.""" - incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) + 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) @@ -198,8 +201,8 @@ async def handle_workflow_submission_event(ack, body, client, context, db_sessio instance = workflow_service.create_instance( db_session=db_session, + workflow=workflow, instance_in=WorkflowInstanceCreate( - workflow=workflow, incident=incident, creator=creator, run_reason=form_data[RunWorkflowBlockIds.reason_input], @@ -243,7 +246,6 @@ async def handle_run_workflow_select_action(ack, body, db_session, context, clie "selected_option" ]["value"] - incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) selected_workflow = workflow_service.get(db_session=db_session, workflow_id=workflow_id) blocks = [ @@ -252,7 +254,6 @@ async def handle_run_workflow_select_action(ack, body, db_session, context, clie initial_option={"text": selected_workflow.name, "value": selected_workflow.id}, db_session=db_session, dispatch_action=True, - project_id=incident.project.id, ), reason_input(), ] diff --git a/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue b/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue index 315cc0e723d4..0d6b2f35dee1 100644 --- a/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue +++ b/src/dispatch/static/dispatch/src/workflow/WorkflowInstanceTab.vue @@ -1,11 +1,5 @@