Skip to content

Commit

Permalink
refactor(slack): dedicated signal folder, break down commands by feat…
Browse files Browse the repository at this point in the history
…ure, build reusable components
  • Loading branch information
wssheldon committed Nov 1, 2024
1 parent 96b0b87 commit 2b62cef
Show file tree
Hide file tree
Showing 8 changed files with 658 additions and 265 deletions.
2 changes: 2 additions & 0 deletions src/dispatch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,7 @@ def run_slack_websocket(organization: str, project: str):
from dispatch.plugins.dispatch_slack.bolt import app
from dispatch.plugins.dispatch_slack.case.interactive import configure as case_configure
from dispatch.plugins.dispatch_slack.incident.interactive import configure as incident_configure
from dispatch.plugins.dispatch_slack.signal.interactive import configure as signal_configure
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
Expand Down Expand Up @@ -1054,6 +1055,7 @@ def run_slack_websocket(organization: str, project: str):
incident_configure(instance.configuration)
workflow_configure(instance.configuration)
case_configure(instance.configuration)
signal_configure(instance.configuration)

app._token = instance.configuration.api_bot_token.get_secret_value()

Expand Down
259 changes: 1 addition & 258 deletions src/dispatch/plugins/dispatch_slack/case/interactive.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
import logging
from datetime import datetime, timedelta, timezone
from datetime import datetime
from functools import partial
from uuid import UUID

Expand All @@ -19,7 +19,6 @@
from slack_bolt import Ack, BoltContext, Respond
from slack_sdk.errors import SlackApiError
from slack_sdk.web.client import WebClient
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from dispatch.auth.models import DispatchUser, MfaChallengeStatus
Expand All @@ -32,7 +31,6 @@
from dispatch.entity import service as entity_service
from dispatch.enums import EventType, SubjectNames, UserRoles, Visibility
from dispatch.event import service as event_service
from dispatch.exceptions import ExistsError
from dispatch.individual.models import IndividualContactRead
from dispatch.participant import service as participant_service
from dispatch.participant.models import ParticipantUpdate
Expand Down Expand Up @@ -91,21 +89,16 @@
from dispatch.plugins.dispatch_slack.modals.common import send_success_modal
from dispatch.plugins.dispatch_slack.models import (
CaseSubjects,
FormData,
FormMetadata,
SignalSubjects,
SubjectMetadata,
)
from dispatch.project import service as project_service
from dispatch.search.utils import create_filter_expression
from dispatch.service import flows as service_flows
from dispatch.signal import service as signal_service
from dispatch.signal.enums import SignalEngagementStatus
from dispatch.signal.models import (
Signal,
SignalEngagement,
SignalFilter,
SignalFilterCreate,
SignalInstance,
)
from dispatch.ticket import flows as ticket_flows
Expand Down Expand Up @@ -726,256 +719,6 @@ def handle_snooze_preview_event(
ack(response_action="update", view=modal)


@app.view(
SignalSnoozeActions.submit,
middleware=[
action_context_middleware,
db_middleware,
user_middleware,
],
)
def handle_snooze_submission_event(
ack: Ack,
body: dict,
client: WebClient,
context: BoltContext,
db_session: Session,
user: DispatchUser,
) -> None:
"""Handle the submission event of the snooze modal.
This function is executed when a user submits the snooze modal. It first
sends an MFA push notification to the user to confirm the action. If the
user accepts the MFA prompt, the function retrieves the relevant information
from the form data and creates a new signal filter. The new filter is then
added to the existing filters for the signal. Finally, the function updates
the modal view to show the result of the operation.
Args:
ack (Ack): The acknowledgement function.
body (dict): The request body.
client (WebClient): The Slack API client.
context (BoltContext): The context data.
db_session (Session): The database session.
user (DispatchUser): The Dispatch user who submitted the form.
"""
mfa_plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=context["subject"].project_id, plugin_type="auth-mfa"
)
mfa_enabled = True if mfa_plugin else False

def _create_snooze_filter(
db_session: Session,
subject: SubjectMetadata,
user: DispatchUser,
) -> None:
form_data: FormData = subject.form_data
# Get the existing filters for the signal
signal = signal_service.get(db_session=db_session, signal_id=subject.id)
# Create the new filter from the form data
if form_data.get(DefaultBlockIds.entity_select):
entities = [
{"id": int(entity.value)} for entity in form_data[DefaultBlockIds.entity_select]
]
else:
entities = []

description = form_data[DefaultBlockIds.description_input]
name = form_data[DefaultBlockIds.title_input]
delta: str = form_data[DefaultBlockIds.relative_date_picker_input].value
# Check if the 'delta' string contains days
# Example: '1 day, 0:00:00' contains days, while '0:01:00' does not
if ", " in delta:
# Split the 'delta' string into days and time parts
# Example: '1 day, 0:00:00' -> days: '1 day' and time_str: '0:00:00'
days, time_str = delta.split(", ")

# Extract the integer value of days from the days string
# Example: '1 day' -> 1
days = int(days.split(" ")[0])
else:
# If the 'delta' string does not contain days, set days to 0
days = 0

# Directly assign the 'delta' string to the time_str variable
time_str = delta

# Split the 'time_str' variable into hours, minutes, and seconds
# Convert each part to an integer
# Example: '0:01:00' -> hours: 0, minutes: 1, seconds: 0
hours, minutes, seconds = [int(x) for x in time_str.split(":")]

# Create a timedelta object using the extracted days, hours, minutes, and seconds
delta = timedelta(
days=days,
hours=hours,
minutes=minutes,
seconds=seconds,
)

# Calculate the new date by adding the timedelta object to the current date and time
date = datetime.now(tz=timezone.utc) + delta

project = project_service.get(db_session=db_session, project_id=signal.project_id)

# None expression is for cases when no entities are selected, in which case
# the filter will apply to all instances of the signal
if entities:
filters = {
"entity": entities,
}
expression = create_filter_expression(filters, "Entity")
else:
expression = []

# Create a new filter with the selected entities and entity types
filter_in = SignalFilterCreate(
name=name,
description=description,
expiration=date,
expression=expression,
project=project,
)
try:
new_filter = signal_service.create_signal_filter(
db_session=db_session, creator=user, signal_filter_in=filter_in
)
except IntegrityError:
raise ExistsError("A signal filter with this name already exists.") from None

signal.filters.append(new_filter)
db_session.commit()
return new_filter

channel_id = context["subject"].channel_id
thread_id = context["subject"].thread_id

# Check if last_mfa_time was within the last hour
if not mfa_enabled:
new_filter = _create_snooze_filter(
db_session=db_session,
user=user,
subject=context["subject"],
)
signal = signal_service.get(db_session=db_session, signal_id=context["subject"].id)
post_snooze_message(
db_session=db_session,
client=client,
channel=channel_id,
user=user,
signal=signal,
new_filter=new_filter,
thread_ts=thread_id,
)
send_success_modal(
client=client,
view_id=body["view"]["id"],
title="Add Snooze",
message="Snooze Filter added successfully.",
)
else:
challenge, challenge_url = mfa_plugin.instance.create_mfa_challenge(
action="signal-snooze",
current_user=user,
db_session=db_session,
project_id=context["subject"].project_id,
)
ack_mfa_required_submission_event(
ack=ack, mfa_enabled=mfa_enabled, challenge_url=challenge_url
)

# wait for the mfa challenge
response = mfa_plugin.instance.wait_for_challenge(
challenge_id=challenge.challenge_id,
db_session=db_session,
)

if response == MfaChallengeStatus.APPROVED:
new_filter = _create_snooze_filter(
db_session=db_session,
user=user,
subject=context["subject"],
)
signal = signal_service.get(db_session=db_session, signal_id=context["subject"].id)
post_snooze_message(
db_session=db_session,
client=client,
channel=channel_id,
user=user,
signal=signal,
new_filter=new_filter,
thread_ts=thread_id,
)
send_success_modal(
client=client,
view_id=body["view"]["id"],
title="Add Snooze",
message="Snooze Filter added successfully.",
)
user.last_mfa_time = datetime.now()
db_session.commit()
else:
if response == MfaChallengeStatus.EXPIRED:
text = "Adding Snooze failed, the MFA request timed out."
elif response == MfaChallengeStatus.DENIED:
text = "Adding Snooze failed, challenge did not complete succsfully."
else:
text = "Adding Snooze failed, you must accept the MFA prompt."

modal = Modal(
title="Add Snooze",
close="Close",
blocks=[Section(text=text)],
).build()

client.views_update(
view_id=body["view"]["id"],
view=modal,
)


def post_snooze_message(
client: WebClient,
channel: str,
user: DispatchUser,
signal: Signal,
db_session: Session,
new_filter: SignalFilter,
thread_ts: str | None = None,
):
def extract_entity_ids(expression: list[dict]) -> list[int]:
entity_ids = []
for item in expression:
if isinstance(item, dict) and "or" in item:
for condition in item["or"]:
if condition.get("model") == "Entity" and condition.get("field") == "id":
entity_ids.append(int(condition.get("value")))
return entity_ids

entity_ids = extract_entity_ids(new_filter.expression)

if entity_ids:
entities = []
for entity_id in entity_ids:
entity = entity_service.get(db_session=db_session, entity_id=entity_id)
if entity:
entities.append(entity)
entities_text = ", ".join([f"{entity.value} ({entity.id})" for entity in entities])
else:
entities_text = "All"

message = (
f":zzz: *New Signal Snooze Added*\n"
f"• User: {user.email}\n"
f"• Signal: {signal.name}\n"
f"• Snooze Name: {new_filter.name}\n"
f"• Description: {new_filter.description}\n"
f"• Expiration: {new_filter.expiration}\n"
f"• Entities: {entities_text}"
)
client.chat_postMessage(channel=channel, text=message, thread_ts=thread_ts)


def assignee_select(
placeholder: str = "Select Assignee",
initial_user: str = None,
Expand Down
16 changes: 9 additions & 7 deletions src/dispatch/plugins/dispatch_slack/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
from http import HTTPStatus
import json
from http import HTTPStatus

from fastapi import APIRouter, HTTPException, Depends
from starlette.background import BackgroundTask
from starlette.responses import JSONResponse
from fastapi import APIRouter, Depends, HTTPException
from slack_sdk.signature import SignatureVerifier
from sqlalchemy import true
from starlette.requests import Request, Headers
from starlette.background import BackgroundTask
from starlette.requests import Headers, Request
from starlette.responses import JSONResponse

from dispatch.database.core import refetch_db_session
from dispatch.plugin.models import Plugin, PluginInstance

from .bolt import app
from .case.interactive import configure as case_configure
from .feedback.interactive import configure as feedback_configure
from .handler import SlackRequestHandler
from .incident.interactive import configure as incident_configure
from .feedback.interactive import configure as feedback_configure
from .workflow import configure as workflow_configure
from .messaging import get_incident_conversation_command_message
from .signal.interactive import configure as signal_configure
from .workflow import configure as workflow_configure

router = APIRouter()

Expand Down Expand Up @@ -64,6 +65,7 @@ def get_request_handler(request: Request, body: bytes, organization: str) -> Sla
feedback_configure(p.configuration)
incident_configure(p.configuration)
workflow_configure(p.configuration)
signal_configure(p.configuration)
app._configuration = p.configuration
app._token = p.configuration.api_bot_token.get_secret_value()
app._signing_secret = p.configuration.signing_secret.get_secret_value()
Expand Down
Empty file.
Empty file.
13 changes: 13 additions & 0 deletions src/dispatch/plugins/dispatch_slack/signal/interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from dispatch.plugins.dispatch_slack.bolt import app
from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration
from dispatch.plugins.dispatch_slack.middleware import db_middleware

from .list import handle_list_signals_command


def configure(config: SlackConversationConfiguration):
"""Maps commands/events to their functions."""

app.command(config.slack_command_list_signals, middleware=[db_middleware])(
handle_list_signals_command
)
Loading

0 comments on commit 2b62cef

Please sign in to comment.