From 48f363b3c89dfc6ea6c1b9eb632b8191f75ea8d4 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Fri, 11 Jul 2025 10:37:21 -0700 Subject: [PATCH 1/4] feat(slack): adds slash command for read-in summaries --- .../settings/plugins/configuring-slack.mdx | 3 +- .../versions/2025-07-11_aa87efd3d6c1.py | 103 +++++++++++++ src/dispatch/plugins/dispatch_slack/config.py | 5 + .../dispatch_slack/incident/interactive.py | 135 ++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/dispatch/database/revisions/tenant/versions/2025-07-11_aa87efd3d6c1.py diff --git a/docs/docs/administration/settings/plugins/configuring-slack.mdx b/docs/docs/administration/settings/plugins/configuring-slack.mdx index 0268c35ad689..099bb3a0b3e9 100644 --- a/docs/docs/administration/settings/plugins/configuring-slack.mdx +++ b/docs/docs/administration/settings/plugins/configuring-slack.mdx @@ -191,7 +191,8 @@ You can override their values if you wish to do so. Included below are their des | `/dispatch-notifications-group` | Opens a modal to edit the notifications group. | | `/dispatch-update-participant` | Opens a modal to update participant metadata. | | `/dispatch-create-task` | Opens a modal to create an incident task. | -| `/dispatch-create-case` | Opens a modal to create a case. | +| `/dispatch-create-case` | Opens a modal to create a case. | +| `/dispatch-summary` | If allowed for this case/incident type, will create an AI-generated read-in summary. | ### Contact Information Resolver Plugin diff --git a/src/dispatch/database/revisions/tenant/versions/2025-07-11_aa87efd3d6c1.py b/src/dispatch/database/revisions/tenant/versions/2025-07-11_aa87efd3d6c1.py new file mode 100644 index 000000000000..8c0d1d435f73 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2025-07-11_aa87efd3d6c1.py @@ -0,0 +1,103 @@ +"""Adds new Slack command for read-in summary generation + +Revision ID: aa87efd3d6c1 +Revises: f63ad392dbbf +Create Date: 2025-07-11 10:02:39.819258 + +""" + +from alembic import op +from pydantic import SecretStr, ValidationError +from pydantic.json import pydantic_encoder + +from sqlalchemy import Column, Integer, ForeignKey, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, Session +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine +from dispatch.config import config, DISPATCH_ENCRYPTION_KEY + + +# revision identifiers, used by Alembic. +revision = "aa87efd3d6c1" +down_revision = "f63ad392dbbf" +branch_labels = None +depends_on = None + +Base = declarative_base() + + +def show_secrets_encoder(obj): + if isinstance(obj, SecretStr): + return obj.get_secret_value() + else: + return pydantic_encoder(obj) + + +def migrate_config(instances, slug, config): + for instance in instances: + if slug == instance.plugin.slug: + instance.configuration = config + + +class Plugin(Base): + __tablename__ = "plugin" + __table_args__ = {"schema": "dispatch_core"} + id = Column(Integer, primary_key=True) + slug = Column(String, unique=True) + + +class PluginInstance(Base): + __tablename__ = "plugin_instance" + id = Column(Integer, primary_key=True) + _configuration = Column( + StringEncryptedType(key=str(DISPATCH_ENCRYPTION_KEY), engine=AesEngine, padding="pkcs5") + ) + plugin_id = Column(Integer, ForeignKey(Plugin.id)) + plugin = relationship(Plugin, backref="instances") + + @hybrid_property + def configuration(self): + """Property that correctly returns a plugins configuration object.""" + pass + + @configuration.setter + def configuration(self, configuration): + """Property that correctly sets a plugins configuration object.""" + if configuration: + self._configuration = configuration.json(encoder=show_secrets_encoder) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + from dispatch.plugins.dispatch_slack.config import SlackConversationConfiguration + + bind = op.get_bind() + session = Session(bind=bind) + + instances = session.query(PluginInstance).all() + + # Slash commands + SLACK_COMMAND_SUMMARY_SLUG = config("SLACK_COMMAND_SUMMARY_SLUG", default="/dispatch-summary") + + try: + slack_conversation_config = SlackConversationConfiguration( + slack_command_summary=SLACK_COMMAND_SUMMARY_SLUG, + ) + + migrate_config(instances, "slack-conversation", slack_conversation_config) + + except ValidationError: + print( + "Skipping automatic migration of slack plugin credentials, if you are using the slack plugin manually migrate credentials." + ) + + session.commit() + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/src/dispatch/plugins/dispatch_slack/config.py b/src/dispatch/plugins/dispatch_slack/config.py index 37c47a84e9ba..ce42fbc1b374 100644 --- a/src/dispatch/plugins/dispatch_slack/config.py +++ b/src/dispatch/plugins/dispatch_slack/config.py @@ -165,3 +165,8 @@ class SlackConversationConfiguration(SlackConfiguration): title="Engage User Command String", description="Defines the string used to engage a user via MFA prompt. Must match what is defined in Slack.", ) + slack_command_summary: str = Field( + "/dispatch-summary", + title="Generate Summary Command String", + description="Defines the string used to generate a summary. Must match what is defined in Slack.", + ) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 427882c6ce99..caa5ebffe4f6 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -227,6 +227,8 @@ def configure(config): ) app.command(config.slack_command_create_task, middleware=middleware)(handle_create_task_command) + app.command(config.slack_command_summary, middleware=middleware)(handle_summary_command) + app.event( event="reaction_added", matchers=[is_target_reaction(config.timeline_event_reaction)], @@ -3005,3 +3007,136 @@ def handle_remind_again_select_action( respond( text=message, response_type="ephemeral", replace_original=False, delete_original=False ) + + +def handle_summary_command( + ack: Ack, + body: dict, + client: WebClient, + context: BoltContext, + db_session: Session, + user: DispatchUser, +) -> None: + """Handles the summary command to generate a read-in summary.""" + ack() + + try: + if context["subject"].type == IncidentSubjects.incident: + incident = incident_service.get( + db_session=db_session, incident_id=int(context["subject"].id) + ) + project = incident.project + subject_type = "incident" + + if incident.visibility == Visibility.restricted: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Cannot generate summary for restricted incidents.", + ) + return + + if not incident.incident_type.generate_read_in_summary: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Read-in summaries are not enabled for this incident type.", + ) + return + + elif context["subject"].type == CaseSubjects.case: + case = case_service.get(db_session=db_session, case_id=int(context["subject"].id)) + project = case.project + subject_type = "case" + + if case.visibility == Visibility.restricted: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Cannot generate summary for restricted cases.", + ) + return + + if not case.case_type.generate_read_in_summary: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Read-in summaries are not enabled for this case type.", + ) + return + else: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Error: Unable to determine subject type for summary generation.", + ) + return + + # All validations passed + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":hourglass_flowing_sand: Generating read-in summary... This may take a moment.", + ) + + summary_response = ai_service.generate_read_in_summary( + db_session=db_session, + subject=context["subject"], + project=project, + channel_id=context["channel_id"], + important_reaction=context["config"].timeline_event_reaction, + participant_email=user.email, + ) + + if summary_response and summary_response.summary: + blocks = create_read_in_summary_blocks(summary_response.summary) + blocks.append( + Context( + elements=[ + MarkdownText( + text="NOTE: The block above was AI-generated and may contain errors or inaccuracies. Please verify the information before relying on it." + ) + ] + ).build() + ) + + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=f"Here is a summary of what has happened so far in this {subject_type}", + blocks=blocks, + ) + elif summary_response and summary_response.error_message: + log.warning(f"Failed to generate read-in summary: {summary_response.error_message}") + + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Unable to generate summary at this time. Please try again later.", + ) + else: + # No summary generated + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: No summary could be generated. There may not be enough information available.", + ) + + except Exception as e: + log.error(f"Error generating summary: {e}") + + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: An error occurred while generating the summary. Please try again later.", + ) From 0fd13078f292b76fed5cbf104c07c62e9bdd40f7 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Fri, 11 Jul 2025 10:42:36 -0700 Subject: [PATCH 2/4] adds new command to test --- tests/plugins/test_dispatch_slack_incident_interactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugins/test_dispatch_slack_incident_interactive.py b/tests/plugins/test_dispatch_slack_incident_interactive.py index 4cac09479204..f17a5bc73264 100644 --- a/tests/plugins/test_dispatch_slack_incident_interactive.py +++ b/tests/plugins/test_dispatch_slack_incident_interactive.py @@ -1,4 +1,3 @@ - def test_configure(): """Test that we can configure the plugin.""" from dispatch.plugins.dispatch_slack.incident.interactive import ( @@ -32,6 +31,7 @@ def test_configure(): "slack_command_list_workflow": "/dispatch-list-workflows", "slack_command_list_tasks": "/dispatch-list-tasks", "slack_command_create_task": "/dispatch-create-task", + "slack_command_summary": "/dispatch-summary", } ) From aeab6058b280b47a7701a20d0fc6dbdfd835756e Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Fri, 11 Jul 2025 11:22:03 -0700 Subject: [PATCH 3/4] Only generate summary for dedicate channel cases --- .../dispatch_slack/incident/interactive.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index caa5ebffe4f6..3d7d9e699872 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -227,7 +227,15 @@ def configure(config): ) app.command(config.slack_command_create_task, middleware=middleware)(handle_create_task_command) - app.command(config.slack_command_summary, middleware=middleware)(handle_summary_command) + app.command( + config.slack_command_summary, + middleware=[ + message_context_middleware, + subject_middleware, + configuration_middleware, + user_middleware, + ], + )(handle_summary_command) app.event( event="reaction_added", @@ -1260,6 +1268,8 @@ def handle_member_joined_channel( generate_read_in_summary = getattr(case.case_type, "generate_read_in_summary", False) if case.visibility == Visibility.restricted: generate_read_in_summary = False + if not case.dedicated_channel: + generate_read_in_summary = False participant.user_conversation_id = context["user_id"] @@ -3068,6 +3078,15 @@ def handle_summary_command( text=":x: Read-in summaries are not enabled for this case type.", ) return + + if not case.dedicated_channel: + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=":x: Read-in summaries are only available for cases with a dedicated channel.", + ) + return else: dispatch_slack_service.send_ephemeral_message( client=client, From b9ac6e944ae9e26eca577ada5555163ff080c69b Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Fri, 11 Jul 2025 11:24:31 -0700 Subject: [PATCH 4/4] cleaning up subject_type --- src/dispatch/plugins/dispatch_slack/incident/interactive.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 3d7d9e699872..74d34672af80 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1185,10 +1185,10 @@ def handle_member_joined_channel( time.sleep(1) generate_read_in_summary = False - subject_type = "incident" + subject_type = context["subject"].type project = None - if context["subject"].type == IncidentSubjects.incident: + if subject_type == IncidentSubjects.incident: participant = incident_flows.incident_add_or_reactivate_participant_flow( user_email=user.email, incident_id=int(context["subject"].id), db_session=db_session ) @@ -1247,7 +1247,7 @@ def handle_member_joined_channel( db_session.add(participant) db_session.commit() - if context["subject"].type == CaseSubjects.case: + if subject_type == CaseSubjects.case: subject_type = "case" case = case_service.get(db_session=db_session, case_id=int(context["subject"].id))