Skip to content

Commit 36a3f84

Browse files
Merge branch 'main' into claude/sync-upstream-release-J7S7H
2 parents d6b267f + 08c42fa commit 36a3f84

14 files changed

Lines changed: 727 additions & 44 deletions

src/chat_sdk/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@
6767
from chat_sdk.logger import ConsoleLogger, Logger, LogLevel
6868
from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig
6969
from chat_sdk.modals import (
70+
ExternalSelect,
71+
ExternalSelectElement,
7072
Modal,
7173
ModalChild,
7274
ModalElement,
75+
OptionsLoadGroup,
7376
RadioSelect,
7477
RadioSelectElement,
7578
Select,
@@ -78,6 +81,7 @@
7881
SelectOptionElement,
7982
TextInput,
8083
TextInputElement,
84+
external_select,
8185
filter_modal_children,
8286
is_modal_element,
8387
modal,
@@ -158,6 +162,7 @@
158162
ModalResponse,
159163
ModalSubmitEvent,
160164
OptionsLoadEvent,
165+
OptionsLoadResult,
161166
PlanUpdateChunk,
162167
Postable,
163168
PostableAst,
@@ -296,6 +301,7 @@
296301
"MessageHistoryCache",
297302
"MessageHistoryConfig",
298303
# Modal builders (PascalCase primary — matches source TS SDK)
304+
"ExternalSelect",
299305
"Modal",
300306
"RadioSelect",
301307
"Select",
@@ -305,11 +311,14 @@
305311
"modal",
306312
"text_input",
307313
"select",
314+
"external_select",
308315
"select_option",
309316
"radio_select",
310317
# Modal types
318+
"ExternalSelectElement",
311319
"ModalChild",
312320
"ModalElement",
321+
"OptionsLoadGroup",
313322
"RadioSelectElement",
314323
"SelectElement",
315324
"SelectOptionElement",
@@ -363,6 +372,7 @@
363372
"ModalSubmitEvent",
364373
"OptionsLoadEvent",
365374
"OptionsLoadHandler",
375+
"OptionsLoadResult",
366376
"PlanUpdateChunk",
367377
"Postable",
368378
"PostableAst",

src/chat_sdk/adapters/google_chat/cards.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from typing import Any, cast
1212

13+
from chat_sdk.adapters.google_chat.format_converter import GoogleChatFormatConverter
1314
from chat_sdk.cards import (
1415
CardChild,
1516
CardElement,
@@ -18,10 +19,18 @@
1819
)
1920
from chat_sdk.shared import card_to_fallback_text as shared_card_to_fallback_text
2021
from chat_sdk.shared import create_emoji_converter
22+
from chat_sdk.shared.base_format_converter import parse_markdown
2123

2224
# Convert emoji placeholders in text to GChat format (Unicode).
2325
convert_emoji = create_emoji_converter("gchat")
2426

27+
_gchat_converter = GoogleChatFormatConverter()
28+
29+
30+
def _render_markdown_as_gchat(text: str) -> str:
31+
"""Parse standard markdown and render as Google Chat formatted text."""
32+
return _gchat_converter.from_ast(parse_markdown(text))
33+
2534

2635
def card_to_google_card(
2736
card: CardElement,
@@ -148,25 +157,13 @@ def _convert_child_to_widgets(
148157
return []
149158

150159

151-
def _markdown_to_gchat(text: str) -> str:
152-
"""Convert standard Markdown formatting to Google Chat formatting.
153-
154-
**bold** -> *bold*
155-
"""
156-
import re
157-
158-
return re.sub(r"\*\*(.+?)\*\*", r"*\1*", text)
159-
160-
161160
def _convert_text_to_widget(element: dict[str, Any]) -> dict[str, Any]:
162161
"""Convert a text element to a widget."""
163-
text = _markdown_to_gchat(convert_emoji(element.get("content", "")))
162+
text = _render_markdown_as_gchat(convert_emoji(element.get("content", "")))
164163

165-
style = element.get("style")
166-
if style == "bold":
164+
if element.get("style") == "bold":
167165
text = f"*{text}*"
168-
elif style == "muted":
169-
# GChat doesn't have muted, use regular text
166+
elif element.get("style") == "muted":
170167
text = convert_emoji(element.get("content", ""))
171168

172169
return {"textParagraph": {"text": text}}
@@ -302,8 +299,8 @@ def _convert_fields_to_widgets(
302299
return [
303300
{
304301
"decoratedText": {
305-
"topLabel": _markdown_to_gchat(convert_emoji(field.get("label", ""))),
306-
"text": _markdown_to_gchat(convert_emoji(field.get("value", ""))),
302+
"topLabel": _render_markdown_as_gchat(convert_emoji(field.get("label", ""))),
303+
"text": _render_markdown_as_gchat(convert_emoji(field.get("value", ""))),
307304
},
308305
}
309306
for field in element.get("children", [])

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
)
5353
from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack
5454
from chat_sdk.logger import ConsoleLogger, Logger
55-
from chat_sdk.modals import ModalElement, SelectOptionElement
55+
from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement
5656
from chat_sdk.shared.adapter_utils import extract_card, extract_files
5757
from chat_sdk.shared.errors import AdapterRateLimitError, AuthenticationError, ValidationError
5858
from chat_sdk.types import (
@@ -1098,24 +1098,70 @@ def _late_error(t: asyncio.Task[Any]) -> None:
10981098

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

1101-
def _options_load_response(self, options_list: list[SelectOptionElement]) -> dict[str, Any]:
1102-
"""Serialize ``SelectOptionElement`` entries to a Slack JSON response."""
1103-
slack_options: list[dict[str, Any]] = []
1104-
for opt in options_list[:100]:
1105-
entry: dict[str, Any] = {
1106-
"text": {"type": "plain_text", "text": opt.get("label", "")},
1107-
"value": opt.get("value", ""),
1101+
def _options_load_response(
1102+
self,
1103+
result: list[SelectOptionElement] | list[OptionsLoadGroup],
1104+
) -> dict[str, Any]:
1105+
"""Serialize a flat option list or grouped option list to a Slack JSON response.
1106+
1107+
Mirrors upstream ``optionsLoadResponse``: when the first entry has an
1108+
``options`` key it is treated as a list of :class:`OptionsLoadGroup`
1109+
and rendered as ``option_groups``; otherwise it's a flat list of
1110+
:class:`SelectOptionElement` rendered as ``options``. Slack's spec is
1111+
explicit that the two are mutually exclusive (only one may appear in
1112+
the response body).
1113+
"""
1114+
# Detect grouped form (TS: ``"options" in result[0]``). A grouped
1115+
# entry is a dict with an ``options`` list inside it; a flat entry is
1116+
# a dict with ``label``/``value`` keys.
1117+
is_groups = (
1118+
len(result) > 0
1119+
and isinstance(result[0], dict)
1120+
and "options" in result[0]
1121+
and isinstance(result[0].get("options"), list)
1122+
)
1123+
1124+
if is_groups:
1125+
groups_in = cast("list[OptionsLoadGroup]", result)[:100]
1126+
slack_groups: list[dict[str, Any]] = []
1127+
for group in groups_in:
1128+
group_options = group.get("options", [])[:100]
1129+
slack_groups.append(
1130+
{
1131+
# Slack spec: group label is plain_text, max 75 chars.
1132+
"label": {"type": "plain_text", "text": group.get("label", "")[:75]},
1133+
"options": [self._select_option_to_slack(opt) for opt in group_options],
1134+
}
1135+
)
1136+
return {
1137+
"body": json.dumps({"option_groups": slack_groups}),
1138+
"status": 200,
1139+
"headers": {"Content-Type": "application/json"},
11081140
}
1109-
desc = opt.get("description")
1110-
if desc:
1111-
entry["description"] = {"type": "plain_text", "text": desc}
1112-
slack_options.append(entry)
1141+
1142+
flat = cast("list[SelectOptionElement]", result)[:100]
11131143
return {
1114-
"body": json.dumps({"options": slack_options}),
1144+
"body": json.dumps({"options": [self._select_option_to_slack(opt) for opt in flat]}),
11151145
"status": 200,
11161146
"headers": {"Content-Type": "application/json"},
11171147
}
11181148

1149+
@staticmethod
1150+
def _select_option_to_slack(opt: SelectOptionElement) -> dict[str, Any]:
1151+
"""Convert a :class:`SelectOptionElement` to Slack's option object shape.
1152+
1153+
Mirrors upstream ``selectOptionToSlackOption`` — the ``description``
1154+
key is omitted (not set to ``null``) when not provided.
1155+
"""
1156+
entry: dict[str, Any] = {
1157+
"text": {"type": "plain_text", "text": opt.get("label", "")},
1158+
"value": opt.get("value", ""),
1159+
}
1160+
desc = opt.get("description")
1161+
if desc:
1162+
entry["description"] = {"type": "plain_text", "text": desc}
1163+
return entry
1164+
11191165
# ==================================================================
11201166
# View submission / close
11211167
# ==================================================================
@@ -1605,7 +1651,7 @@ async def _resolve_outgoing_mentions(self, text: str, thread_id: str) -> str:
16051651
return text
16061652
state = self._chat.get_state()
16071653

1608-
mention_pattern = re.compile(r"@(\w+)")
1654+
mention_pattern = re.compile(r"(?<![\w<])@(\w+)")
16091655
mentions: dict[str, list[str]] = {}
16101656

16111657
for match in mention_pattern.finditer(text):

src/chat_sdk/adapters/slack/format_converter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def flush_text() -> None:
200200

201201
def _convert_mentions_to_slack(self, text: str) -> str:
202202
"""Convert @mentions to Slack format: @name -> <@name>."""
203-
return re.sub(r"(?<!<)@(\w+)", r"<@\1>", text)
203+
return re.sub(r"(?<![\w<])@(\w+)", r"<@\1>", text)
204204

205205
def _node_to_mrkdwn(self, node: Content) -> str:
206206
"""Convert a single AST node to Slack mrkdwn."""
@@ -215,7 +215,7 @@ def _node_to_mrkdwn(self, node: Content) -> str:
215215

216216
if node_type == "text":
217217
value = node.get("value", "")
218-
return re.sub(r"(?<!<)@(\w+)", r"<@\1>", value)
218+
return re.sub(r"(?<![\w<])@(\w+)", r"<@\1>", value)
219219

220220
if node_type == "strong":
221221
content = "".join(self._node_to_mrkdwn(c) for c in children)

src/chat_sdk/adapters/slack/modals.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def _modal_child_to_block(child: ModalChild) -> SlackBlock:
102102
return _text_input_to_block(child) # type: ignore[arg-type]
103103
if child_type == "select":
104104
return _select_to_block(child) # type: ignore[arg-type]
105+
if child_type == "external_select":
106+
return _external_select_to_block(child) # type: ignore[arg-type]
105107
if child_type == "radio_select":
106108
return _radio_select_to_block(child) # type: ignore[arg-type]
107109
if child_type == "text":
@@ -179,6 +181,58 @@ def _select_to_block(select: dict[str, Any]) -> SlackBlock:
179181
}
180182

181183

184+
def _external_select_to_block(select: dict[str, Any]) -> SlackBlock:
185+
"""Convert an :class:`ExternalSelectElement` to a Slack input block with external_select.
186+
187+
Mirrors upstream ``externalSelectToBlock``. Options are loaded at runtime
188+
by an ``onOptionsLoad`` handler — this just emits the placeholder element.
189+
Optional fields (``placeholder``, ``min_query_length``, ``initial_option``)
190+
are omitted from the output when not set, matching upstream behavior.
191+
"""
192+
element: dict[str, Any] = {
193+
"type": "external_select",
194+
"action_id": select.get("id", ""),
195+
}
196+
197+
placeholder = select.get("placeholder")
198+
if placeholder:
199+
element["placeholder"] = {"type": "plain_text", "text": placeholder}
200+
201+
min_query_length = select.get("min_query_length")
202+
# Use ``is not None`` (hazard #1): ``0`` is a valid Slack value meaning
203+
# "fire on every keystroke" and must not silently fall back to omitting
204+
# the key (which would default to Slack's 3-character minimum).
205+
if min_query_length is not None:
206+
element["min_query_length"] = min_query_length
207+
208+
initial_option = select.get("initial_option")
209+
if initial_option is not None:
210+
# Hazard #1: ``is not None`` (not truthiness) so a hand-constructed
211+
# empty dict ``{}`` doesn't silently render as no initial_option,
212+
# matching the TS ``if (select.initialOption)`` semantics where
213+
# ``{}`` is truthy. Also keeps consistency with the
214+
# ``min_query_length is not None`` check above.
215+
# Unlike static select, ``initial_option`` is the full
216+
# ``{label, value}`` object — the loader hasn't run yet so a value
217+
# string would be ambiguous. Mirrors selectOptionToSlackOption.
218+
slack_initial: dict[str, Any] = {
219+
"text": {"type": "plain_text", "text": initial_option.get("label", "")},
220+
"value": initial_option.get("value", ""),
221+
}
222+
desc = initial_option.get("description")
223+
if desc:
224+
slack_initial["description"] = {"type": "plain_text", "text": desc}
225+
element["initial_option"] = slack_initial
226+
227+
return {
228+
"type": "input",
229+
"block_id": select.get("id", ""),
230+
"optional": select.get("optional", False),
231+
"label": {"type": "plain_text", "text": select.get("label", "")},
232+
"element": element,
233+
}
234+
235+
182236
def _radio_select_to_block(radio_select: dict[str, Any]) -> SlackBlock:
183237
"""Convert a RadioSelectElement to a Slack input block with radio_buttons."""
184238
limited_options = radio_select.get("options", [])[:10]

src/chat_sdk/chat.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter
2222
from chat_sdk.errors import ChatError, LockError
2323
from chat_sdk.logger import ConsoleLogger, Logger
24-
from chat_sdk.modals import SelectOptionElement
2524
from chat_sdk.thread import (
2625
ThreadImpl,
2726
_active_chat,
@@ -56,6 +55,7 @@
5655
ModalSubmitEvent,
5756
OnLockConflict,
5857
OptionsLoadEvent,
58+
OptionsLoadResult,
5959
QueueEntry,
6060
ReactionEvent,
6161
SlashCommandEvent,
@@ -87,7 +87,7 @@
8787
ActionHandler = Callable[[ActionEvent], Any]
8888
OptionsLoadHandler = Callable[
8989
[OptionsLoadEvent],
90-
Awaitable[list[SelectOptionElement] | None] | list[SelectOptionElement] | None,
90+
Awaitable[OptionsLoadResult | None] | OptionsLoadResult | None,
9191
]
9292
ModalSubmitHandler = Callable[[ModalSubmitEvent], Any]
9393
ModalCloseHandler = Callable[[ModalCloseEvent], Any]
@@ -953,7 +953,7 @@ async def process_options_load(
953953
self,
954954
event: OptionsLoadEvent,
955955
options: WebhookOptions | None = None, # noqa: ARG002 (match upstream signature)
956-
) -> list[SelectOptionElement] | None:
956+
) -> OptionsLoadResult | None:
957957
"""Process an options-load event (external-select suggestion lookup).
958958
959959
Runs specific-action-ID handlers before catch-all handlers and returns

0 commit comments

Comments
 (0)