Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ Then, under the `Subscribe to bot events` section, add the following bot user ev

To enable Dispatch to generate interactive components such as dialogs and modals, ensure that the `Request URL` points to the events action API endpoint of the Dispatch server at `/api/v1/<organization>/events/slack/action`. Replace `<organization>` with your organization's name. The default one is `default`.

### Select Menus

On the same `Interactivity & Shortcuts` page, under `Select Menus`, set the `Options Load URL` to `/api/v1/<organization>/events/slack/menu`. Replace `<organization>` with your organization's name. The default one is `default`.

This is a separate field from the `Request URL` above and does not inherit from it. Dispatch uses it for the type-ahead menus that load their choices on demand rather than embedding them in the modal: tag selection, and project selection in deployments with more than 100 enabled projects. If it is unset, those menus open empty and stay empty, and because the field is required the form cannot be submitted.

### Slash Commands

To enable Dispatch's slash commands, you must add them to your Slack app first. Ensure that the `Command` field in the Slack API Web UI matches the name of the command name in the Slack plugin in the Dispatch Web UI and that the `Request URL` points to the events command API endpoint of the Dispatch server at `/api/v1/<organization>/events/slack/command`.
Expand Down
11 changes: 6 additions & 5 deletions src/dispatch/plugins/dispatch_slack/case/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
extension_request_checkbox,
incident_priority_select,
incident_type_select,
project_option,
project_select,
relative_date_picker_input,
resolution_input,
Expand Down Expand Up @@ -194,7 +195,7 @@ def handle_escalate_case_command(

default_title = case.title
default_description = case.description
default_project = {"text": case.project.display_name, "value": case.project.id}
default_project = project_option(case.project)

blocks = [
Context(elements=[Text(type="mrkdwn", text="Accept the defaults or adjust as needed.")]),
Expand Down Expand Up @@ -1472,7 +1473,7 @@ def escalate_button_click(
description_input(initial_value=case.description),
project_select(
db_session=db_session,
initial_option={"text": case.project.display_name, "value": case.project.id},
initial_option=project_option(case.project),
action_id=CaseEscalateActions.project_select,
dispatch_action=True,
),
Expand Down Expand Up @@ -1529,7 +1530,7 @@ def handle_project_select_action(
description_input(),
project_select(
db_session=db_session,
initial_option={"text": project.display_name, "value": project.id},
initial_option=project_option(project),
action_id=CaseEscalateActions.project_select,
dispatch_action=True,
),
Expand Down Expand Up @@ -2407,7 +2408,7 @@ def handle_report_project_select_action(
description_input(),
project_select(
db_session=db_session,
initial_option={"text": project.display_name, "value": project.id},
initial_option=project_option(project),
action_id=CaseReportActions.project_select,
dispatch_action=True,
),
Expand Down Expand Up @@ -2517,7 +2518,7 @@ def handle_report_case_type_select_action(
description_input(),
project_select(
db_session=db_session,
initial_option={"text": project.display_name, "value": project.id},
initial_option=project_option(project),
action_id=CaseReportActions.project_select,
dispatch_action=True,
),
Expand Down
8 changes: 8 additions & 0 deletions src/dispatch/plugins/dispatch_slack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

MAX_SECTION_TEXT_LENGTH = 2999

# Slack caps a static select at 100 options and an options-load response at 100
# options. Both limits are the same number and both are hard: over either one
# the payload is rejected, so nothing may be built or returned past this.
MAX_SELECT_OPTIONS = 100

# Longest plain_text an option label may carry.
MAX_OPTION_TEXT_LENGTH = 75


class SlackConfiguration(BaseConfigurationModel):
"""Slack configuration description."""
Expand Down
14 changes: 12 additions & 2 deletions src/dispatch/plugins/dispatch_slack/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
from .workflow import configure as workflow_configure
from .messaging import get_incident_conversation_command_message

# Registers the options-load listener with `app` on import; nothing here calls
# into it, so without this the /slack/menu route has no project handler.
from . import options # noqa: F401

router = APIRouter()


Expand Down Expand Up @@ -139,7 +143,13 @@ async def slack_actions(request: Request, organization: str, body: bytes = Depen
@router.post(
"/slack/menu",
)
async def slack_menus(request: Request, organization: str, body: bytes = Depends(get_body)):
"""Handle all incoming Slack menus."""
def slack_menus(request: Request, organization: str, body: bytes = Depends(get_body)):
"""Handle all incoming Slack menus.

Deliberately not `async`. `handler.handle` is synchronous all the way down
and Bolt polls for the listener's ack in a sleep loop, so awaiting it here
would block the event loop -- for every keystroke, now that the project
select loads its options through this route.
"""
handler = get_request_handler(request=request, body=body, organization=organization)
return handler.handle(req=request, body=body)
109 changes: 101 additions & 8 deletions src/dispatch/plugins/dispatch_slack/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from blockkit import (
Checkboxes,
DatePicker,
ExternalSelect,
Input,
MultiExternalSelect,
MultiStaticSelect,
Expand All @@ -24,8 +25,13 @@
from dispatch.incident.severity import service as incident_severity_service
from dispatch.incident.type import service as incident_type_service
from dispatch.participant.models import Participant
from dispatch.plugins.dispatch_slack.config import MAX_SECTION_TEXT_LENGTH
from dispatch.plugins.dispatch_slack.config import (
MAX_OPTION_TEXT_LENGTH,
MAX_SECTION_TEXT_LENGTH,
MAX_SELECT_OPTIONS,
)
from dispatch.project import service as project_service
from dispatch.project.models import Project
from dispatch.signal.models import Signal

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -321,6 +327,76 @@ def multi_select_block(
)


def external_select_block(
placeholder: str,
action_id: str = None,
block_id: str = None,
initial_option: dict[str, str] = None,
label: str = None,
# Slack's own default is 3 characters. One keeps the menu responsive on a
# short project name while still bounding how much a keystroke costs. It is
# also the floor the blockkit library imposes -- Slack itself accepts 0,
# but blockkit refuses to build it, and 0 would buy nothing here: Slack
# loads the menu on open regardless, and this only gates typing.
min_query_length: int = 1,
**kwargs,
):
"""Builds an external select block, whose options are loaded on demand.

Slack asks for the options over the block_suggestion route rather than
reading them out of the view, so nothing here is bounded by the 100-option
limit a static select carries.
"""
processed_initial_option = None
if initial_option:
processed_initial_option = {
k: str(v) if k == "value" else v for k, v in initial_option.items()
}

return Input(
element=ExternalSelect(
placeholder=placeholder,
initial_option=(
Option(**processed_initial_option) if processed_initial_option else None
),
min_query_length=min_query_length,
action_id=action_id,
),
block_id=block_id,
label=label,
**kwargs,
)


def project_label(project: Project) -> str:
"""The option text for a project, within Slack's length limit.

Never empty, because Slack rejects an option with empty text and this is
used for the preselected option too, where no query has filtered anything
out. ``display_name`` is only defaulted to '' at the column level, so a
project created without one falls back to its name -- the same pairing the
rest of Dispatch lists a project under, and what the migration that added
the column seeded it from. Falling back past that to the id keeps a
project whose name is null selectable rather than dropping it from the
menu; it cannot be found by typing, since there is nothing to match on.
"""
label = project.display_name or project.name or f"Project {project.id}"
return label[:MAX_OPTION_TEXT_LENGTH]


def project_option(project: Project) -> dict[str, str]:
"""Builds the select option for a project.

The text is what a project is called everywhere else it is listed; the
value is its id, which is what every handler reads back. They are not
interchangeable -- two projects may share a display name.
"""
return {
"text": project_label(project),
"value": str(project.id),
}


def project_select(
db_session: Session,
action_id: str = DefaultActionIds.project_select,
Expand All @@ -329,19 +405,36 @@ def project_select(
initial_option: dict = None,
**kwargs,
):
"""Creates a project select."""
projects = [
{"text": p.display_name, "value": p.id}
for p in project_service.get_all(db_session=db_session)
if p.enabled
]
"""Creates a project select.

Embedding the options only works while there are few enough of them to
embed, so past Slack's limit this hands over to an external select and the
options handler in ``.options``. Both produce the same block and action
ids and the same ``selected_option`` shape, so callers cannot tell which
one they got.
"""
# One row past the limit is all it takes to know which of the two applies,
# and is far cheaper than counting a large project table.
projects = project_service.get_all_enabled(db_session=db_session, limit=MAX_SELECT_OPTIONS + 1)
if not projects:
log.warning("Unable to create a select block for projects. No projects found.")
return

if len(projects) > MAX_SELECT_OPTIONS:
return external_select_block(
# Slack loads the menu on open, so this is still a browse; past a
# hundred projects it is also worth saying that typing narrows it.
placeholder="Select or search for a project",
initial_option=initial_option,
action_id=action_id,
block_id=block_id,
label=label,
**kwargs,
)

return static_select_block(
placeholder="Select Project",
options=projects,
options=[project_option(p) for p in projects],
initial_option=initial_option,
action_id=action_id,
block_id=block_id,
Expand Down
20 changes: 17 additions & 3 deletions src/dispatch/plugins/dispatch_slack/incident/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
from dispatch.plugins.dispatch_slack.bolt import app
from dispatch.plugins.dispatch_slack.decorators import message_dispatcher
from dispatch.plugins.dispatch_slack.enums import SlackAPIErrorCode
from dispatch.plugins.dispatch_slack.exceptions import CommandError, EventError
from dispatch.plugins.dispatch_slack.exceptions import CommandError, EventError, SubmissionError
from dispatch.plugins.dispatch_slack.fields import (
DefaultActionIds,
DefaultBlockIds,
Expand All @@ -72,6 +72,7 @@
incident_status_select,
incident_type_select,
participant_select,
project_option,
project_select,
resolution_input,
static_select_block,
Expand Down Expand Up @@ -335,7 +336,7 @@ def handle_update_incident_project_select_action(
incident_status_select(initial_option={"text": incident.status, "value": incident.status}),
project_select(
db_session=db_session,
initial_option={"text": project.display_name, "value": project.id},
initial_option=project_option(project),
action_id=IncidentUpdateActions.project_select,
dispatch_action=True,
),
Expand Down Expand Up @@ -2739,7 +2740,19 @@ def handle_report_incident_submission_event(
tag = tag_service.get(db_session=db_session, tag_id=int(t["value"]))
tags.append(tag)

project = {"name": form_data[DefaultBlockIds.project_select]["name"]}
# Resolved from the option's value, not its text: the text is the project's
# label, which is its display name where it has one and is truncated to
# Slack's 75 characters, while the lookup downstream matches on `name` and
# silently falls back to the default project when it misses.
selected_project = project_service.get(
db_session=db_session,
project_id=int(form_data[DefaultBlockIds.project_select]["value"]),
)
if not selected_project:
raise SubmissionError(
msg="The selected project no longer exists. Please reopen this form and try again."
)
project = {"name": selected_project.name}

incident_type = None
if form_data.get(DefaultBlockIds.incident_type_select):
Expand Down Expand Up @@ -2849,6 +2862,7 @@ def handle_report_incident_project_select_action(
description_input(),
project_select(
db_session=db_session,
initial_option=project_option(project),
action_id=IncidentReportActions.project_select,
dispatch_action=True,
),
Expand Down
82 changes: 82 additions & 0 deletions src/dispatch/plugins/dispatch_slack/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Options-load handler for the project select.

Slack asks for an external select's options over the block_suggestion route
rather than reading them out of the view, which is what lets the project select
carry more projects than a static select's 100-option limit allows (#86).
Requests arrive at the ``/slack/menu`` endpoint, which verifies the signature
and picks the organization before anything here runs.

Slack will not send them anywhere unless the app's *Options Load URL* is set,
which is a separate field from the Request URL -- see
docs/administration/settings/plugins/configuring-slack.

The tag lookup is the other options handler and still lives in
incident/interactive.py, though both flows use it. Consolidating them here is
worth doing, but not as part of this fix.
"""

import logging
import re

from slack_bolt import Ack, BoltContext
from sqlalchemy.orm import Session

from dispatch.project import service as project_service

from .bolt import app
from .config import MAX_SELECT_OPTIONS
from .fields import project_option
from .middleware import action_context_middleware, db_middleware

log = logging.getLogger(__name__)

# Every project select's action id ends in "project-select" -- the shared
# default plus one per flow. Matching on the suffix rather than listing them
# means a new call site with its own action id is served too; without a
# matching handler its select would open to a permanently empty menu. Bolt
# matches with `search`, so the leading boundary has to be explicit or an
# unrelated "subproject-select" would be served this list as well.
PROJECT_SELECT_ACTION_ID_PATTERN = re.compile(r"(^|-)project-select$")


@app.options(
PROJECT_SELECT_ACTION_ID_PATTERN, middleware=[action_context_middleware, db_middleware]
)
def handle_project_search_action(
ack: Ack, payload: dict, context: BoltContext, db_session: Session
) -> None:
"""Serves the project type-ahead.

``db_middleware`` binds the session to the organization the modal was
opened in, so this offers exactly what the static select would have -- every
enabled project in that organization's schema, which is the only place the
query can reach.
"""
try:
projects = project_service.get_all_enabled(
db_session=db_session,
query_str=payload.get("value"),
limit=MAX_SELECT_OPTIONS,
)
except Exception:
# Slack renders whatever comes back; an error here would leave the user
# staring at a spinner, and the detail belongs in the log, not in a menu.
# The slug is what tells an operator which tenant is broken.
log.exception(
"Unable to load project options for the Slack project select. Organization: %s",
context["subject"].organization_slug,
)
return ack(options=[])

options = []
for project in projects:
option = project_option(project)
options.append(
{
"text": {"type": "plain_text", "text": option["text"]},
# NOTE: slack doesn't accept int's as values (fails silently)
"value": option["value"],
}
)

ack(options=options)
Loading
Loading