Skip to content
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ Parity catch-up with upstream `4.26.0`. No upstream version change.
who've posted in the thread. Seeds from `current_message.author` (if
eligible), then iterates `all_messages()` and dedupes by `user_id`.
Mirrors upstream TS `Thread.getParticipants()`. Issue #54.
- **`Chat.on_options_load(...)` + `Chat.process_options_load(...)`**: port of
upstream `onOptionsLoad` / `processOptionsLoad` for handling
external-select option-load events. Specific action IDs run before
catch-all handlers; errors are logged and skipped so later handlers still
get a chance. New public types: `OptionsLoadEvent`, `OptionsLoadHandler`.
- **Slack `block_suggestion` dispatch**: the Slack adapter now routes
`block_suggestion` interactive payloads through `process_options_load`
and serializes the result to a Slack options JSON response. The handler
is raced against a 2.5s budget (`OPTIONS_LOAD_TIMEOUT_MS`); on timeout
the response is empty options and the orphaned task still logs errors
via `asyncio.shield`. Issue #50.
- **`IoRedisStateAdapter`**: `RedisStateAdapter` subclass defaulting to the
`ioredis_` lock-token prefix used by upstream Vercel Chat's `ioredis`-backed
state. Enables cross-runtime Redis sharing between TS and Python chat-sdk
Expand Down
5 changes: 4 additions & 1 deletion src/chat_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
text_element,
)
from chat_sdk.channel import ChannelImpl
from chat_sdk.chat import Chat
from chat_sdk.chat import Chat, OptionsLoadHandler
from chat_sdk.emoji import (
EmojiResolver,
convert_emoji_placeholders,
Expand Down Expand Up @@ -155,6 +155,7 @@
ModalCloseEvent,
ModalResponse,
ModalSubmitEvent,
OptionsLoadEvent,
PlanUpdateChunk,
Postable,
PostableAst,
Expand Down Expand Up @@ -356,6 +357,8 @@
"ModalCloseEvent",
"ModalResponse",
"ModalSubmitEvent",
"OptionsLoadEvent",
"OptionsLoadHandler",
"PlanUpdateChunk",
"Postable",
"PostableAst",
Expand Down
114 changes: 113 additions & 1 deletion 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
from chat_sdk.modals import ModalElement, 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 @@ -82,6 +82,7 @@
ModalCloseEvent,
ModalResponse,
ModalSubmitEvent,
OptionsLoadEvent,
RawMessage,
ReactionEvent,
ScheduledMessage,
Expand All @@ -93,6 +94,10 @@
WebhookOptions,
)

# Slack expects block_suggestion responses within 3s. Leave headroom for
# network latency so the HTTP response lands before Slack gives up.
OPTIONS_LOAD_TIMEOUT_MS = 2500

# Strong-reference set for fire-and-forget tasks to prevent GC collection.
_background_tasks: set[asyncio.Task[Any]] = set()

Expand Down Expand Up @@ -881,6 +886,8 @@ async def _handle_interactive_payload(self, body: str, options: WebhookOptions |
if payload_type == "block_actions":
self._handle_block_actions(payload, options)
return {"body": "", "status": 200}
elif payload_type == "block_suggestion":
return await self._handle_block_suggestion(payload, options)
elif payload_type == "view_submission":
return await self._handle_view_submission(payload, options)
elif payload_type == "view_closed":
Expand Down Expand Up @@ -1001,6 +1008,111 @@ def _handle_block_actions(self, payload: dict[str, Any], options: WebhookOptions
)
self._chat.process_action(action_event, options)

# ==================================================================
# Block suggestion (external-select options load)
# ==================================================================

async def _handle_block_suggestion(
self, payload: dict[str, Any], options: WebhookOptions | None = None
) -> dict[str, Any]:
"""Handle a Slack block_suggestion interactive payload.

Slack requires a response within 3s for block_suggestion and does not
support an async ack pattern — options must be in the response body.
Race the handler against a 2.5s budget and fall back to an empty 200
so the menu shows "No results" instead of hanging for the user.
"""
if not self._chat:
self._logger.warn("Chat instance not initialized, ignoring block suggestion")
return self._options_load_response([])

user_ref = payload.get("user") or {}
user_id = user_ref.get("id", "")
username = user_ref.get("username")
name = user_ref.get("name")
# Upstream uses `||` truthy-fallthrough intentionally: an empty-string
# username falls through to name, then user_id. See upstream
# packages/adapter-slack/src/index.ts lines ~1258-1260.
user_name = username or name or user_id
full_name = name or username or user_id

action_id = payload.get("action_id", "")
val = payload.get("value")
event = OptionsLoadEvent(
action_id=action_id,
query=val if val is not None else "",
user=Author(
user_id=user_id,
user_name=user_name,
full_name=full_name,
is_bot=False,
is_me=False,
),
adapter=self,
raw=payload,
)

# Use asyncio.shield so the orphaned task still runs (and logs errors)
# if we time out. `wait_for` cancels the awaitable on timeout; shielding
# prevents that cancellation from propagating into the handler task.
load_task = asyncio.ensure_future(self._chat.process_options_load(event, options))

try:
result = await asyncio.wait_for(asyncio.shield(load_task), timeout=OPTIONS_LOAD_TIMEOUT_MS / 1000.0)
except asyncio.TimeoutError:
self._logger.warn(
"Options load handler timed out",
{"action_id": action_id, "timeout_ms": OPTIONS_LOAD_TIMEOUT_MS},
)

def _late_error(t: asyncio.Task[Any]) -> None:
if t.cancelled():
return
exc = t.exception()
if exc is not None:
self._logger.error(
"Options load handler error after timeout",
{"action_id": action_id, "error": str(exc)},
)

load_task.add_done_callback(_late_error)
_pin_task(load_task)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register timed-out options task with wait_until

When block_suggestion times out, the handler task is only pinned in-process and immediately returned, but it is never passed to WebhookOptions.wait_until. In runtimes that end request work unless tasks are explicitly registered (the same pattern already used elsewhere in this adapter), the timed-out process_options_load task can be terminated before completion, so the intended late-error logging path may never run. This breaks the commit’s stated timeout behavior specifically in webhook environments that rely on wait_until for background work.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 71e2113 — timed-out handler task now registers with options.wait_until (matching _handle_reaction_event pattern), then guarded with try/except in 6e96891 in case runtime-provided wait_until raises.

# Register with wait_until so serverless/webhook runtimes
# (e.g. Vercel) keep the task alive past the HTTP response;
# otherwise the late-error logging path above can be killed
# before it runs. wait_until is user/runtime-provided, so
# guard against it raising — we still want to return the
# empty-options HTTP 200 fallback.
if options and options.wait_until:
try:
options.wait_until(load_task)
except Exception as err:
self._logger.warn(
"wait_until raised while registering timed-out options load task",
{"action_id": action_id, "error": str(err)},
)
return self._options_load_response([])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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", ""),
}
desc = opt.get("description")
if desc:
entry["description"] = {"type": "plain_text", "text": desc}
slack_options.append(entry)
return {
"body": json.dumps({"options": slack_options}),
"status": 200,
"headers": {"Content-Type": "application/json"},
}

# ==================================================================
# View submission / close
# ==================================================================
Expand Down
87 changes: 87 additions & 0 deletions src/chat_sdk/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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 @@ -53,6 +54,7 @@
ModalResponse,
ModalSubmitEvent,
OnLockConflict,
OptionsLoadEvent,
QueueEntry,
ReactionEvent,
SlashCommandEvent,
Expand Down Expand Up @@ -82,6 +84,10 @@
SubscribedMessageHandler = Callable[[Any, Message, Any], Awaitable[None] | None]
ReactionHandler = Callable[[ReactionEvent], Any]
ActionHandler = Callable[[ActionEvent], Any]
OptionsLoadHandler = Callable[
[OptionsLoadEvent],
Awaitable[list[SelectOptionElement] | None] | list[SelectOptionElement] | None,
]
ModalSubmitHandler = Callable[[ModalSubmitEvent], Any]
ModalCloseHandler = Callable[[ModalCloseEvent], Any]
SlashCommandHandler = Callable[[SlashCommandEvent], Any]
Expand Down Expand Up @@ -121,6 +127,14 @@ def __init__(self, action_ids: list[str], handler: ActionHandler) -> None:
self.handler = handler


class _OptionsLoadPattern:
__slots__ = ("action_ids", "handler")

def __init__(self, action_ids: list[str], handler: OptionsLoadHandler) -> None:
self.action_ids = action_ids
self.handler = handler


class _ModalSubmitPattern:
__slots__ = ("callback_ids", "handler")

Expand Down Expand Up @@ -358,6 +372,7 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None:
self._subscribed_message_handlers: list[SubscribedMessageHandler] = []
self._reaction_handlers: list[_ReactionPattern] = []
self._action_handlers: list[_ActionPattern] = []
self._options_load_handlers: list[_OptionsLoadPattern] = []
self._modal_submit_handlers: list[_ModalSubmitPattern] = []
self._modal_close_handlers: list[_ModalClosePattern] = []
self._slash_command_handlers: list[_SlashCommandPattern] = []
Expand Down Expand Up @@ -654,6 +669,47 @@ def decorator(h: ActionHandler) -> ActionHandler:

return decorator

# -- Options load ---

def on_options_load(
self,
action_ids_or_handler: str | list[str] | OptionsLoadHandler | None = None,
handler: OptionsLoadHandler | None = None,
) -> OptionsLoadHandler | Callable[[OptionsLoadHandler], OptionsLoadHandler]:
"""Register a handler for loading dynamic options for external selects.

Specific action IDs run before catch-all handlers.

Overloaded:
- ``chat.on_options_load(handler)`` -- all selects
- ``chat.on_options_load("id", handler)``
- ``chat.on_options_load(["id1", "id2"], handler)``
- Decorator: ``@chat.on_options_load("id")``
"""
if callable(action_ids_or_handler) and handler is None:
self._options_load_handlers.append(_OptionsLoadPattern([], action_ids_or_handler))
self._logger.debug("Registered options load handler for all action IDs")
return action_ids_or_handler

if isinstance(action_ids_or_handler, (str, list)) and handler is not None:
ids = [action_ids_or_handler] if isinstance(action_ids_or_handler, str) else action_ids_or_handler
self._options_load_handlers.append(_OptionsLoadPattern(ids, handler))
self._logger.debug("Registered options load handler", {"action_ids": ids})
return handler

# Decorator form
ids = (
[action_ids_or_handler]
if isinstance(action_ids_or_handler, str)
else (action_ids_or_handler if isinstance(action_ids_or_handler, list) else [])
)

def decorator(h: OptionsLoadHandler) -> OptionsLoadHandler:
self._options_load_handlers.append(_OptionsLoadPattern(ids, h))
return h

return decorator

# -- Modal submit ---

def on_modal_submit(
Expand Down Expand Up @@ -892,6 +948,37 @@ def process_action(
if options and options.wait_until:
options.wait_until(task)

async def process_options_load(
self,
event: OptionsLoadEvent,
options: WebhookOptions | None = None, # noqa: ARG002 (match upstream signature)
) -> list[SelectOptionElement] | None:
"""Process an options-load event (external-select suggestion lookup).

Runs specific-action-ID handlers before catch-all handlers and returns
the first handler result that isn't ``None`` — including an explicit
``[]``, which short-circuits subsequent handlers (handler says "I
handled this action, show no options"). Errors are logged and skipped
so later handlers still get a chance. Mirrors upstream
``processOptionsLoad`` (TS ``if (options) { return options; }``, where
``[]`` is truthy and therefore short-circuits).
"""
matching_handlers = [
pat for pat in self._options_load_handlers if pat.action_ids and event.action_id in pat.action_ids
] + [pat for pat in self._options_load_handlers if not pat.action_ids]

for pat in matching_handlers:
try:
result = await self._invoke_handler(pat.handler, event)
if result is not None:
return result
except Exception as exc:
self._logger.error(
"Options load handler error",
{"action_id": event.action_id, "error": str(exc)},
)
return None

async def process_modal_submit(
self,
event: ModalSubmitEvent,
Expand Down
20 changes: 20 additions & 0 deletions src/chat_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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


def _parse_iso(s: str) -> datetime:
Expand Down Expand Up @@ -987,6 +988,22 @@ async def open_modal(self, modal: Any) -> dict[str, str] | None:
return None


@dataclass
class OptionsLoadEvent:
"""Event emitted when an adapter needs dynamic options for an external select.

Port of upstream TS ``OptionsLoadEvent``. Slack dispatches a
``block_suggestion`` payload to populate an external-select menu; the
handler returns the matching options for the current query text.
"""

action_id: str
adapter: Adapter
query: str
user: Author
raw: Any = None


@dataclass
class ModalSubmitEvent:
"""Modal form submission event."""
Expand Down Expand Up @@ -1348,6 +1365,9 @@ def process_slash_command(self, event: Any, options: WebhookOptions | None = Non
def process_modal_submit(
self, event: Any, context_id: str | None = None, options: WebhookOptions | None = None
) -> Awaitable[ModalResponse | None]: ...
def process_options_load(
self, event: OptionsLoadEvent, options: WebhookOptions | None = None
) -> Awaitable[list[SelectOptionElement] | None]: ...
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — standard typing.Protocol method body per PEP 544. Consistent with every other Protocol method in this file.

def process_modal_close(
self, event: Any, context_id: str | None = None, options: WebhookOptions | None = None
) -> None: ...
Expand Down
Loading
Loading