diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 753020726107..48da6b849adf 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -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 @@ -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() diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 69c3e2381193..ea22ab9913f0 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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, diff --git a/src/dispatch/plugins/dispatch_slack/endpoints.py b/src/dispatch/plugins/dispatch_slack/endpoints.py index 9ccdf425c215..b5f3f177b93d 100644 --- a/src/dispatch/plugins/dispatch_slack/endpoints.py +++ b/src/dispatch/plugins/dispatch_slack/endpoints.py @@ -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() @@ -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() diff --git a/src/dispatch/plugins/dispatch_slack/signal/__init__.py b/src/dispatch/plugins/dispatch_slack/signal/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/plugins/dispatch_slack/signal/enums.py b/src/dispatch/plugins/dispatch_slack/signal/enums.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/plugins/dispatch_slack/signal/interactive.py b/src/dispatch/plugins/dispatch_slack/signal/interactive.py new file mode 100644 index 000000000000..9765a59788a1 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/signal/interactive.py @@ -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 + ) diff --git a/src/dispatch/plugins/dispatch_slack/signal/list.py b/src/dispatch/plugins/dispatch_slack/signal/list.py new file mode 100644 index 000000000000..52755d13b038 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/signal/list.py @@ -0,0 +1,312 @@ +import json + +from blockkit import ( + Actions, + Button, + Context, + Divider, + MarkdownText, + Modal, + Section, +) +from slack_bolt import Ack, BoltContext +from slack_sdk.web.client import WebClient +from sqlalchemy.orm import Session + +from dispatch.plugins.dispatch_slack import service as dispatch_slack_service +from dispatch.plugins.dispatch_slack.bolt import app +from dispatch.plugins.dispatch_slack.case.enums import ( + CasePaginateActions, + SignalNotificationActions, +) +from dispatch.plugins.dispatch_slack.middleware import ( + action_context_middleware, + db_middleware, +) +from dispatch.plugins.dispatch_slack.models import ( + SignalSubjects, + SubjectMetadata, +) +from dispatch.project import service as project_service +from dispatch.signal import service as signal_service + + +def handle_list_signals_command( + ack: Ack, + body: dict, + db_session: Session, + context: BoltContext, + client: WebClient, +) -> None: + ack() + + projects = project_service.get_all(db_session=db_session) + conversation_name = dispatch_slack_service.get_conversation_name_by_id( + client, context.channel_id + ) + + signals = [] + for project in projects: + signals.extend( + signal_service.get_all_by_conversation_target( + db_session=db_session, project_id=project.id, conversation_target=conversation_name + ) + ) + + if not signals: + modal = Modal( + title="Signal Definition List", + blocks=[ + Context(elements=[f"There are no signals configured for {conversation_name}"]), + ], + close="Close", + ).build() + + return client.views_open(trigger_id=body["trigger_id"], view=modal) + + limit = 25 + current_page = 0 + total_pages = len(signals) // limit + (1 if len(signals) % limit > 0 else 0) + + _draw_list_signal_modal( + client=client, + body=body, + db_session=db_session, + conversation_name=conversation_name, + current_page=current_page, + total_pages=total_pages, + first_render=True, + ) + + +def _draw_list_signal_modal( + client: WebClient, + body: dict, + db_session: Session, + conversation_name: str, + current_page: int, + total_pages: int, + first_render: bool, +) -> None: + """Draw the signal definition list modal. + + Args: + client (WebClient): A Slack WebClient object that provides a convenient interface to the Slack API. + body (dict): A dictionary that contains the original request payload from Slack. + db_session (Session): A SQLAlchemy database session. + conversation_name (str): The name of the Slack conversation. + current_page (int): The current page number. + total_pages (int): The total number of pages. + first_render (bool): A boolean indicating whether the modal is being rendered for the first time. + + Returns: + None + + Raises: + None + + Example: + client = WebClient(token=) + body = { + "trigger_id": "836215173894.4768581721.6f8ab1fcee0478f0e6c0c2b0dc9f0c7a", + } + db_session = Session() + conversation_name = "test_conversation" + current_page = 0 + total_pages = 3 + first_render = True + _draw_list_signal_modal( + client, body, db_session, conversation_name, current_page, total_pages, first_render + ) + """ + modal = Modal( + title="Signal Definition List", + blocks=_build_signal_list_modal_blocks( + db_session=db_session, + conversation_name=conversation_name, + current_page=current_page, + total_pages=total_pages, + ), + close="Close", + private_metadata=json.dumps( + { + "conversation_name": conversation_name, + "current_page": current_page, + "total_pages": total_pages, + } + ), + ).build() + + ( + client.views_open(trigger_id=body["trigger_id"], view=modal) + if first_render is True + else client.views_update(view_id=body["view"]["id"], view=modal) + ) + + +def _build_signal_list_modal_blocks( + db_session: Session, + conversation_name: str, + current_page: int, + total_pages: int, +) -> list: + """Builds a list of blocks for a modal view displaying signals. + + This function creates a list of blocks that represent signals that are filtered by conversation_name. The list of signals + is paginated and limited to 25 signals per page. + + The function returns the blocks with pagination controls that display the current page and allows navigation to the previous + and next pages. + + Args: + db_session (Session): The database session. + conversation_name (str): The name of the conversation to filter signals by. + current_page (int): The current page being displayed. + total_pages (int): The total number of pages. + + Returns: + list: A list of blocks representing the signals and pagination controls. + + Example: + >>> blocks = _build_signal_list_modal_blocks(db_session, "conversation_name", 1, 2) + >>> len(blocks) + 26 + """ + + blocks = [] + limit = 25 + start_index = current_page * limit + end_index = start_index + limit - 1 + + projects = project_service.get_all(db_session=db_session) + signals = [] + for project in projects: + signals.extend( + signal_service.get_all_by_conversation_target( + db_session=db_session, project_id=project.id, conversation_target=conversation_name + ) + ) + + limited_signals = [] + for idx, signal in enumerate(signals[start_index : end_index + 1], start_index + 1): # noqa + limited_signals.append(signal) + + button_metadata = SubjectMetadata( + type=SignalSubjects.signal, + organization_slug=signal.project.organization.slug, + id=signal.id, + project_id=signal.project.id, + ).json() + + blocks.extend( + [ + Section( + text=signal.name, + accessory=Button( + text="Snooze", + value=button_metadata, + action_id=SignalNotificationActions.snooze, + ), + ), + Context( + elements=[MarkdownText(text=f"{signal.variant}" if signal.variant else "N/A")] + ), + ] + ) + # Don't add a divider if we are at the last signal + if idx != len(signals[start_index : end_index + 1]): # noqa + blocks.extend([Divider()]) + + pagination_blocks = [ + Actions( + block_id="pagination", + elements=[ + Button( + text="Previous", + action_id=CasePaginateActions.list_signal_previous, + style="danger" if current_page == 0 else "primary", + ), + Button( + text="Next", + action_id=CasePaginateActions.list_signal_next, + style="danger" if current_page == total_pages - 1 else "primary", + ), + ], + ) + ] + + return blocks + pagination_blocks if len(signals) > limit else blocks + + +@app.action( + CasePaginateActions.list_signal_next, middleware=[action_context_middleware, db_middleware] +) +def handle_next_action(ack: Ack, body: dict, client: WebClient, db_session: Session): + """Handle the 'next' action in the signal list modal. + + This function is called when the user clicks the 'next' button in the signal list modal. It increments the current page + of the modal and updates the view with the new page. + + Args: + ack (function): The function to acknowledge the action request. + db_session (Session): The database session to query for signal data. + body (dict): The request payload from the action. + client (WebClient): The Slack API WebClient to interact with the Slack API. + """ + ack() + + metadata = json.loads(body["view"]["private_metadata"]) + + current_page = metadata["current_page"] + total_pages = metadata["total_pages"] + conversation_name = metadata["conversation_name"] + + if current_page < total_pages - 1: + current_page += 1 + + _draw_list_signal_modal( + client=client, + body=body, + db_session=db_session, + conversation_name=conversation_name, + current_page=current_page, + total_pages=total_pages, + first_render=False, + ) + + +@app.action( + CasePaginateActions.list_signal_previous, middleware=[action_context_middleware, db_middleware] +) +def handle_previous_action(ack: Ack, body: dict, client: WebClient, db_session: Session): + """Handle the 'previous' action in the signal list modal. + + This function is called when the user clicks the 'previous' button in the signal list modal. It decrements the current page + of the modal and updates the view with the new page. + + Args: + ack (function): The function to acknowledge the action request. + db_session (Session): The database session to query for signal data. + body (dict): The request payload from the action. + client (WebClient): The Slack API WebClient to interact with the Slack API. + """ + ack() + + metadata = json.loads(body["view"]["private_metadata"]) + + current_page = metadata["current_page"] + total_pages = metadata["total_pages"] + conversation_name = metadata["conversation_name"] + + if current_page > 0: + current_page -= 1 + + _draw_list_signal_modal( + client=client, + body=body, + db_session=db_session, + conversation_name=conversation_name, + current_page=current_page, + total_pages=total_pages, + first_render=False, + ) diff --git a/src/dispatch/plugins/dispatch_slack/signal/snooze.py b/src/dispatch/plugins/dispatch_slack/signal/snooze.py new file mode 100644 index 000000000000..dcfe327dc6fe --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/signal/snooze.py @@ -0,0 +1,321 @@ +from dataclasses import dataclass +from typing import List, Optional + +from blockkit import Context, Divider, MarkdownText, Modal, Section +from pydantic import BaseModel, Field +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient +from sqlalchemy.orm import Session + +from dispatch.auth.models import DispatchUser +from dispatch.entity import service as entity_service +from dispatch.plugins.dispatch_slack.bolt import app +from dispatch.plugins.dispatch_slack.case.enums import ( + SignalNotificationActions, + SignalSnoozeActions, +) +from dispatch.plugins.dispatch_slack.fields import ( + DefaultBlockIds, + description_input, + entity_select, + relative_date_picker_input, + title_input, +) +from dispatch.plugins.dispatch_slack.middleware import ( + action_context_middleware, + button_context_middleware, + db_middleware, + modal_submit_middleware, + user_middleware, +) +from dispatch.plugins.dispatch_slack.models import ( + FormMetadata, + SignalSubjects, + SubjectMetadata, +) +from dispatch.signal import service as signal_service +from dispatch.signal.models import ( + Signal, + SignalInstance, +) + + +class SignalSnoozeData(BaseModel): + """Data model for signal snooze operations.""" + + title: str = Field(..., min_length=1) + description: str = Field(..., min_length=1) + expiration_delta: str + entity_ids: List[int] = Field(default_factory=list) + signal_id: int + project_id: int + + +@dataclass +class SnoozeContext: + """Context for snooze operations.""" + + db_session: Session + client: WebClient + body: dict + context: BoltContext + ack: Ack + user: Optional[DispatchUser] = None + form_data: Optional[dict] = None + + +class SnoozeModalBuilder: + """Builder for snooze-related modals.""" + + @staticmethod + def build_initial_modal( + db_session: Session, + signal: Signal, + subject: SubjectMetadata, + ) -> dict: + """Build the initial snooze modal.""" + blocks = [ + Context(elements=[MarkdownText(text=f"{signal.name}")]), + Divider(), + title_input(placeholder="A name for your snooze filter."), + description_input(placeholder="Provide a description for your snooze filter."), + relative_date_picker_input(label="Expiration"), + ] + + entity_select_block = entity_select( + db_session=db_session, + signal_id=signal.id, + optional=True, + ) + + if entity_select_block: + blocks.extend( + [ + entity_select_block, + Context( + elements=[ + MarkdownText( + text="Signals that contain all selected entities will be snoozed for the configured timeframe." + ) + ] + ), + ] + ) + + return Modal( + title="Snooze Signal", + blocks=blocks, + submit="Preview", + close="Close", + callback_id=SignalSnoozeActions.preview, + private_metadata=subject.json(), + ).build() + + @staticmethod + def build_preview_modal( + signal_instances: list[SignalInstance] | None, + form_data: dict, + context: BoltContext, + ) -> dict: + """Build the preview modal.""" + text = ( + "Examples matching your filter:" + if signal_instances + else "No entities selected. All instances of this signal will be snoozed." + if not form_data.get(DefaultBlockIds.entity_select) + else "No signals matching your filter." + ) + + blocks = [Context(elements=[MarkdownText(text=text)])] + + if signal_instances: + for instance in signal_instances[:5]: + blocks.extend(SnoozeModalBuilder._build_instance_blocks(instance)) + + private_metadata = FormMetadata( + form_data=form_data, + **context["subject"].dict(), + ).json() + + return Modal( + title="Add Snooze", + submit="Create", + close="Close", + blocks=blocks, + callback_id=SignalSnoozeActions.submit, + private_metadata=private_metadata, + ).build() + + @staticmethod + def _build_instance_blocks(instance: SignalInstance) -> list[Section | Context]: + """Build blocks for a signal instance preview.""" + return [ + Section(text=instance.signal.name), + Context( + elements=[ + MarkdownText(text=f" Case: {instance.case.name if instance.case else 'N/A'}") + ] + ), + Context( + elements=[ + MarkdownText( + text=f" Created: {instance.case.created_at if instance.case else 'N/A'}" + ) + ] + ), + ] + + +class SnoozeService: + """Service for handling snooze operations.""" + + def __init__(self, context: SnoozeContext): + self.context = context + self.db_session = context.db_session + + def handle_button_click(self) -> None: + """Handle initial snooze button click.""" + self.context.ack() + subject = self._process_subject() + signal = self._get_signal(subject.id) + modal = SnoozeModalBuilder.build_initial_modal(self.db_session, signal, subject) + self._show_modal(modal) + + def handle_preview(self) -> None: + """Handle preview request.""" + title = self.context.form_data.get(DefaultBlockIds.title_input) + + if self._is_name_taken(title): + self._show_name_taken_error(title) + return + + signal_instances = self._get_preview_instances() + modal = SnoozeModalBuilder.build_preview_modal( + signal_instances, self.context.form_data, self.context.context + ) + self.context.ack(response_action="update", view=modal) + + def handle_submission(self) -> None: + """Handle final submission.""" + # Delegate to the submission handler from the previous implementation + pass + + def _process_subject(self) -> SubjectMetadata: + """Process and validate subject metadata.""" + subject = self.context.context["subject"] + if subject.type == SignalSubjects.signal_instance: + instance = signal_service.get_signal_instance( + db_session=self.db_session, signal_instance_id=subject.id + ) + subject.id = instance.signal.id + return subject + + def _get_signal(self, signal_id: int) -> Signal: + """Get signal by ID.""" + return signal_service.get(db_session=self.db_session, signal_id=signal_id) + + def _is_name_taken(self, title: str) -> bool: + """Check if filter name is already taken.""" + return bool( + signal_service.get_signal_filter_by_name( + db_session=self.db_session, + project_id=self.context.context["subject"].project_id, + name=title, + ) + ) + + def _get_preview_instances(self) -> list[SignalInstance] | None: + """Get preview instances based on selected entities.""" + if not (entity_data := self.context.form_data.get(DefaultBlockIds.entity_select)): + return None + + entity_ids = [entity["value"] for entity in entity_data] + return entity_service.get_signal_instances_with_entities( + db_session=self.db_session, + signal_id=self.context.context["subject"].id, + entity_ids=entity_ids, + days_back=90, + ) + + def _show_modal(self, modal: dict) -> None: + """Show modal to user.""" + if view_id := self.context.body.get("view", {}).get("id"): + self.context.client.views_update(view_id=view_id, view=modal) + else: + self.context.client.views_open(trigger_id=self.context.body["trigger_id"], view=modal) + + def _show_name_taken_error(self, title: str) -> None: + """Show error modal for taken names.""" + modal = Modal( + title="Name Taken", + close="Close", + blocks=[ + Context( + elements=[ + MarkdownText( + text=f"A signal filter with the name '{title}' already exists." + ) + ] + ) + ], + ).build() + self.context.ack(response_action="update", view=modal) + + +# Event Handlers +@app.action(SignalNotificationActions.snooze, middleware=[button_context_middleware, db_middleware]) +def snooze_button_click( + ack: Ack, body: dict, client: WebClient, context: BoltContext, db_session: Session +) -> None: + """Handle snooze button click.""" + snooze_context = SnoozeContext( + db_session=db_session, client=client, body=body, context=context, ack=ack + ) + SnoozeService(snooze_context).handle_button_click() + + +@app.view( + SignalSnoozeActions.preview, + middleware=[ + action_context_middleware, + db_middleware, + modal_submit_middleware, + ], +) +def handle_snooze_preview_event( + ack: Ack, + body: dict, + client: WebClient, + context: BoltContext, + db_session: Session, + form_data: dict, +) -> None: + """Handle snooze preview request.""" + snooze_context = SnoozeContext( + db_session=db_session, + client=client, + body=body, + context=context, + ack=ack, + form_data=form_data, + ) + SnoozeService(snooze_context).handle_preview() + + +@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 final snooze submission.""" + snooze_context = SnoozeContext( + db_session=db_session, client=client, body=body, context=context, ack=ack, user=user + ) + SnoozeService(snooze_context).handle_submission()