diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..fbad0b2 Binary files /dev/null and b/.DS_Store differ diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..fe2507d Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/chat_sdk/.DS_Store b/src/chat_sdk/.DS_Store new file mode 100644 index 0000000..e7b9aa5 Binary files /dev/null and b/src/chat_sdk/.DS_Store differ diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index d1168ee..8358a98 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -1605,10 +1605,15 @@ def _handle_block_actions(self, payload: dict[str, Any], options: WebhookOptions channel = (payload.get("channel") or {}).get("id") or (payload.get("container") or {}).get("channel_id") message_ts = (payload.get("message") or {}).get("ts") or (payload.get("container") or {}).get("message_ts") + # DMs have no threads: a button click on a top-level DM message must not + # thread the response. Mirror _handle_message_event's DM handling — keep a + # real in-DM thread_ts, but never fall back to the clicked message's own + # ts, which would spawn a phantom "1 reply" thread in the DM. + is_dm = bool(channel) and channel.startswith("D") thread_ts = ( (payload.get("message") or {}).get("thread_ts") or (payload.get("container") or {}).get("thread_ts") - or message_ts + or ("" if is_dm else message_ts) ) is_view_action = (payload.get("container") or {}).get("type") == "view" @@ -1619,7 +1624,7 @@ def _handle_block_actions(self, payload: dict[str, Any], options: WebhookOptions thread_id = "" if channel and (thread_ts or message_ts): - thread_id = self.encode_thread_id(SlackThreadId(channel=channel, thread_ts=thread_ts or message_ts or "")) + thread_id = self.encode_thread_id(SlackThreadId(channel=channel, thread_ts=thread_ts)) is_ephemeral = (payload.get("container") or {}).get("is_ephemeral") is True response_url = payload.get("response_url") diff --git a/tests/test_slack_webhook.py b/tests/test_slack_webhook.py index 2638789..07fc2b6 100644 --- a/tests/test_slack_webhook.py +++ b/tests/test_slack_webhook.py @@ -337,6 +337,42 @@ async def test_handles_block_actions(self): response = await adapter.handle_webhook(req) assert response["status"] == 200 + @pytest.mark.asyncio + async def test_dm_block_action_does_not_thread(self): + """A click on a top-level DM message must resolve to a DM-root thread_id. + + Regression: _handle_block_actions fell back to message_ts for thread_ts + in DMs, so HITL approval result cards posted via event.thread.post became + phantom "1 reply" threads in the DM. DMs have no threads — the ActionEvent + must carry an empty thread_ts, mirroring _handle_message_event. + """ + adapter = _make_adapter() + chat = _make_mock_chat(_make_mock_state()) + await adapter.initialize(chat) + + req = self._make_interactive_req( + { + "type": "block_actions", + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "D456"}, + "channel": {"id": "D456", "name": "directmessage"}, + "message": {"ts": "1234567890.123456"}, # top-level DM message: no thread_ts + "actions": [{"type": "button", "action_id": "approve_btn", "value": "approved"}], + } + ) + response = await adapter.handle_webhook(req) + assert response["status"] == 200 + + chat.process_action.assert_called_once() + action_event = chat.process_action.call_args[0][0] + decoded = adapter.decode_thread_id(action_event.thread_id) + assert decoded.channel == "D456" + assert decoded.thread_ts == "", ( + f"DM block action threaded under {decoded.thread_ts!r}; a top-level DM click " + "must resolve to a DM-root thread_id (empty thread_ts), else the approval " + "result card posts as a phantom reply thread." + ) + @pytest.mark.asyncio async def test_returns_400_for_missing_payload(self): adapter = _make_adapter()