Skip to content
Draft
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
10 changes: 10 additions & 0 deletions src/chat_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@
from chat_sdk.logger import ConsoleLogger, Logger, LogLevel
from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig
from chat_sdk.modals import (
ExternalSelect,
ExternalSelectElement,
Modal,
ModalChild,
ModalElement,
OptionsLoadGroup,
RadioSelect,
RadioSelectElement,
Select,
Expand All @@ -78,6 +81,7 @@
SelectOptionElement,
TextInput,
TextInputElement,
external_select,
filter_modal_children,
is_modal_element,
modal,
Expand Down Expand Up @@ -158,6 +162,7 @@
ModalResponse,
ModalSubmitEvent,
OptionsLoadEvent,
OptionsLoadResult,
PlanUpdateChunk,
Postable,
PostableAst,
Expand Down Expand Up @@ -296,6 +301,7 @@
"MessageHistoryCache",
"MessageHistoryConfig",
# Modal builders (PascalCase primary — matches source TS SDK)
"ExternalSelect",
"Modal",
"RadioSelect",
"Select",
Expand All @@ -305,11 +311,14 @@
"modal",
"text_input",
"select",
"external_select",
"select_option",
"radio_select",
# Modal types
"ExternalSelectElement",
"ModalChild",
"ModalElement",
"OptionsLoadGroup",
"RadioSelectElement",
"SelectElement",
"SelectOptionElement",
Expand Down Expand Up @@ -363,6 +372,7 @@
"ModalSubmitEvent",
"OptionsLoadEvent",
"OptionsLoadHandler",
"OptionsLoadResult",
"PlanUpdateChunk",
"Postable",
"PostableAst",
Expand Down
72 changes: 59 additions & 13 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
)
from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack
from chat_sdk.logger import ConsoleLogger, Logger
from chat_sdk.modals import ModalElement, SelectOptionElement
from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement
from chat_sdk.shared.adapter_utils import extract_card, extract_files
from chat_sdk.shared.errors import AdapterRateLimitError, AuthenticationError, ValidationError
from chat_sdk.types import (
Expand Down Expand Up @@ -1098,24 +1098,70 @@ def _late_error(t: asyncio.Task[Any]) -> None:

return self._options_load_response(result if result is not None else [])

def _options_load_response(self, options_list: list[SelectOptionElement]) -> dict[str, Any]:
"""Serialize ``SelectOptionElement`` entries to a Slack JSON response."""
slack_options: list[dict[str, Any]] = []
for opt in options_list[:100]:
entry: dict[str, Any] = {
"text": {"type": "plain_text", "text": opt.get("label", "")},
"value": opt.get("value", ""),
def _options_load_response(
self,
result: list[SelectOptionElement] | list[OptionsLoadGroup],
) -> dict[str, Any]:
"""Serialize a flat option list or grouped option list to a Slack JSON response.

Mirrors upstream ``optionsLoadResponse``: when the first entry has an
``options`` key it is treated as a list of :class:`OptionsLoadGroup`
and rendered as ``option_groups``; otherwise it's a flat list of
:class:`SelectOptionElement` rendered as ``options``. Slack's spec is
explicit that the two are mutually exclusive (only one may appear in
the response body).
"""
# Detect grouped form (TS: ``"options" in result[0]``). A grouped
# entry is a dict with an ``options`` list inside it; a flat entry is
# a dict with ``label``/``value`` keys.
is_groups = (
len(result) > 0
and isinstance(result[0], dict)
and "options" in result[0]
and isinstance(result[0].get("options"), list)
)

if is_groups:
groups_in = cast("list[OptionsLoadGroup]", result)[:100]
slack_groups: list[dict[str, Any]] = []
for group in groups_in:
group_options = group.get("options", [])[:100]
slack_groups.append(
{
# Slack spec: group label is plain_text, max 75 chars.
"label": {"type": "plain_text", "text": group.get("label", "")[:75]},
"options": [self._select_option_to_slack(opt) for opt in group_options],
}
)
return {
"body": json.dumps({"option_groups": slack_groups}),
"status": 200,
"headers": {"Content-Type": "application/json"},
}
desc = opt.get("description")
if desc:
entry["description"] = {"type": "plain_text", "text": desc}
slack_options.append(entry)

flat = cast("list[SelectOptionElement]", result)[:100]
return {
"body": json.dumps({"options": slack_options}),
"body": json.dumps({"options": [self._select_option_to_slack(opt) for opt in flat]}),
"status": 200,
"headers": {"Content-Type": "application/json"},
}

@staticmethod
def _select_option_to_slack(opt: SelectOptionElement) -> dict[str, Any]:
"""Convert a :class:`SelectOptionElement` to Slack's option object shape.

Mirrors upstream ``selectOptionToSlackOption`` — the ``description``
key is omitted (not set to ``null``) when not provided.
"""
entry: dict[str, Any] = {
"text": {"type": "plain_text", "text": opt.get("label", "")},
"value": opt.get("value", ""),
}
desc = opt.get("description")
if desc:
entry["description"] = {"type": "plain_text", "text": desc}
return entry

# ==================================================================
# View submission / close
# ==================================================================
Expand Down
54 changes: 54 additions & 0 deletions src/chat_sdk/adapters/slack/modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ def _modal_child_to_block(child: ModalChild) -> SlackBlock:
return _text_input_to_block(child) # type: ignore[arg-type]
if child_type == "select":
return _select_to_block(child) # type: ignore[arg-type]
if child_type == "external_select":
return _external_select_to_block(child) # type: ignore[arg-type]
if child_type == "radio_select":
return _radio_select_to_block(child) # type: ignore[arg-type]
if child_type == "text":
Expand Down Expand Up @@ -179,6 +181,58 @@ def _select_to_block(select: dict[str, Any]) -> SlackBlock:
}


def _external_select_to_block(select: dict[str, Any]) -> SlackBlock:
"""Convert an :class:`ExternalSelectElement` to a Slack input block with external_select.

Mirrors upstream ``externalSelectToBlock``. Options are loaded at runtime
by an ``onOptionsLoad`` handler — this just emits the placeholder element.
Optional fields (``placeholder``, ``min_query_length``, ``initial_option``)
are omitted from the output when not set, matching upstream behavior.
"""
element: dict[str, Any] = {
"type": "external_select",
"action_id": select.get("id", ""),
}

placeholder = select.get("placeholder")
if placeholder:
element["placeholder"] = {"type": "plain_text", "text": placeholder}

min_query_length = select.get("min_query_length")
# Use ``is not None`` (hazard #1): ``0`` is a valid Slack value meaning
# "fire on every keystroke" and must not silently fall back to omitting
# the key (which would default to Slack's 3-character minimum).
if min_query_length is not None:
element["min_query_length"] = min_query_length

initial_option = select.get("initial_option")
if initial_option is not None:
# Hazard #1: ``is not None`` (not truthiness) so a hand-constructed
# empty dict ``{}`` doesn't silently render as no initial_option,
# matching the TS ``if (select.initialOption)`` semantics where
# ``{}`` is truthy. Also keeps consistency with the
# ``min_query_length is not None`` check above.
# Unlike static select, ``initial_option`` is the full
# ``{label, value}`` object — the loader hasn't run yet so a value
# string would be ambiguous. Mirrors selectOptionToSlackOption.
slack_initial: dict[str, Any] = {
"text": {"type": "plain_text", "text": initial_option.get("label", "")},
"value": initial_option.get("value", ""),
}
desc = initial_option.get("description")
if desc:
slack_initial["description"] = {"type": "plain_text", "text": desc}
element["initial_option"] = slack_initial

return {
"type": "input",
"block_id": select.get("id", ""),
"optional": select.get("optional", False),
"label": {"type": "plain_text", "text": select.get("label", "")},
"element": element,
}


def _radio_select_to_block(radio_select: dict[str, Any]) -> SlackBlock:
"""Convert a RadioSelectElement to a Slack input block with radio_buttons."""
limited_options = radio_select.get("options", [])[:10]
Expand Down
6 changes: 3 additions & 3 deletions src/chat_sdk/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter
from chat_sdk.errors import ChatError, LockError
from chat_sdk.logger import ConsoleLogger, Logger
from chat_sdk.modals import SelectOptionElement
from chat_sdk.thread import (
ThreadImpl,
_active_chat,
Expand Down Expand Up @@ -56,6 +55,7 @@
ModalSubmitEvent,
OnLockConflict,
OptionsLoadEvent,
OptionsLoadResult,
QueueEntry,
ReactionEvent,
SlashCommandEvent,
Expand Down Expand Up @@ -87,7 +87,7 @@
ActionHandler = Callable[[ActionEvent], Any]
OptionsLoadHandler = Callable[
[OptionsLoadEvent],
Awaitable[list[SelectOptionElement] | None] | list[SelectOptionElement] | None,
Awaitable[OptionsLoadResult | None] | OptionsLoadResult | None,
]
ModalSubmitHandler = Callable[[ModalSubmitEvent], Any]
ModalCloseHandler = Callable[[ModalCloseEvent], Any]
Expand Down Expand Up @@ -953,7 +953,7 @@ async def process_options_load(
self,
event: OptionsLoadEvent,
options: WebhookOptions | None = None, # noqa: ARG002 (match upstream signature)
) -> list[SelectOptionElement] | None:
) -> OptionsLoadResult | None:
"""Process an options-load event (external-select suggestion lookup).

Runs specific-action-ID handlers before catch-all handlers and returns
Expand Down
64 changes: 62 additions & 2 deletions src/chat_sdk/modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,35 @@ class RadioSelectElement(TypedDict, total=False):
optional: bool


class OptionsLoadGroup(TypedDict):
"""A labeled group of options returned by an ``onOptionsLoad`` handler.

Maps to upstream TS ``OptionsLoadGroup``. Slack ``external_select`` renders
grouped results as ``option_groups`` (mutually exclusive with top-level
``options`` per Slack's spec).
"""

label: str
options: list[SelectOptionElement]


class ExternalSelectElement(TypedDict, total=False):
"""External select form element (loads options dynamically from a handler)."""

type: str # "external_select"
id: str
label: str
placeholder: str
min_query_length: int
optional: bool
# Pre-selected option when the modal opens (must match an option returned
# by the loader). Unlike static :class:`SelectElement`, the initial value
# is the full ``{label, value}`` object since the loader has not run yet.
initial_option: SelectOptionElement


# Union of all modal child types
ModalChild = TextInputElement | SelectElement | RadioSelectElement | TextElement | FieldsElement
ModalChild = TextInputElement | SelectElement | ExternalSelectElement | RadioSelectElement | TextElement | FieldsElement


class ModalElement(TypedDict, total=False):
Expand All @@ -69,7 +96,7 @@ class ModalElement(TypedDict, total=False):
children: list[ModalChild]


VALID_MODAL_CHILD_TYPES = {"text_input", "select", "radio_select", "text", "fields"}
VALID_MODAL_CHILD_TYPES = {"text_input", "select", "external_select", "radio_select", "text", "fields"}


def is_modal_element(value: Any) -> bool:
Expand Down Expand Up @@ -180,6 +207,38 @@ def Select(
return result


def ExternalSelect(
*,
id: str,
label: str,
placeholder: str | None = None,
min_query_length: int | None = None,
optional: bool | None = None,
initial_option: SelectOptionElement | None = None,
) -> ExternalSelectElement:
"""Build an :class:`ExternalSelectElement` dict.

Slack-only: renders to a Block Kit ``external_select`` element whose
options are populated by an :func:`Chat.on_options_load` handler at
runtime. ``initial_option`` is the full ``{label, value}`` object (the
loader hasn't run yet so just a value string would be ambiguous).
"""
result: ExternalSelectElement = {
"type": "external_select",
"id": id,
"label": label,
}
if placeholder is not None:
result["placeholder"] = placeholder
if min_query_length is not None:
result["min_query_length"] = min_query_length
if optional is not None:
result["optional"] = optional
if initial_option is not None:
result["initial_option"] = initial_option
return result


def SelectOption(
*,
label: str,
Expand Down Expand Up @@ -227,5 +286,6 @@ def RadioSelect(
modal = Modal
text_input = TextInput
select = Select
external_select = ExternalSelect
select_option = SelectOption
radio_select = RadioSelect
9 changes: 7 additions & 2 deletions src/chat_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
from chat_sdk.cards import CardElement
from chat_sdk.errors import ChatNotImplementedError
from chat_sdk.logger import Logger, LogLevel
from chat_sdk.modals import SelectOptionElement
from chat_sdk.modals import OptionsLoadGroup, SelectOptionElement

# A handler may return either a flat list of options or a list of labeled
# groups (Slack's ``option_groups`` shape). Mirrors upstream TS
# ``OptionsLoadResult = SelectOptionElement[] | OptionsLoadGroup[]``.
OptionsLoadResult = list[SelectOptionElement] | list[OptionsLoadGroup]


def _parse_iso(s: str) -> datetime:
Expand Down Expand Up @@ -1424,7 +1429,7 @@ def process_modal_submit(
) -> Awaitable[ModalResponse | None]: ...
def process_options_load(
self, event: OptionsLoadEvent, options: WebhookOptions | None = None
) -> Awaitable[list[SelectOptionElement] | None]: ...
) -> Awaitable[OptionsLoadResult | None]: ...
def process_modal_close(
self, event: Any, context_id: str | None = None, options: WebhookOptions | None = None
) -> None: ...
Expand Down
Loading
Loading