diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a44f65..c6f415b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased +Parity catch-up with upstream `4.26.0`. No upstream version change. + ### New public APIs +- **`Thread.get_participants()`**: returns unique non-bot, non-self authors + 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. - **`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 @@ -34,6 +40,12 @@ or federated (workload identity) authentication instead."`). Not a functional implementation; upstream does not implement cert auth either. +### Test fidelity + +- Ported the 4 `[getParticipants]` tests from `thread.test.ts` and the 4 + `[thread]` factory tests from `chat.test.ts` (existing-behavior coverage + for `Chat.thread(id)`). Closes 8 fidelity gaps. + ### Test hygiene - Sweep remaining `time.sleep` → `await asyncio.sleep` in async tests diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 7702fa1..3494ca0 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -430,6 +430,30 @@ async def all_messages(self) -> AsyncIterator[Message]: for msg in cached: yield msg + async def get_participants(self) -> list[Author]: + """Return unique non-bot, non-self authors who've posted in the thread. + + Mirrors ``getParticipants`` from the upstream TS SDK: seeds the + result with ``_current_message.author`` (if present and eligible), + then scans ``all_messages()`` oldest-first, skipping ``is_me`` and + ``is_bot`` authors and deduping by ``user_id``. + """ + seen: dict[str, Author] = {} + + if ( + self._current_message is not None + and not self._current_message.author.is_me + and not self._current_message.author.is_bot + ): + seen[self._current_message.author.user_id] = self._current_message.author + + async for msg in self.all_messages(): + if msg.author.is_me or msg.author.is_bot or msg.author.user_id in seen: + continue + seen[msg.author.user_id] = msg.author + + return list(seen.values()) + # -- Subscriptions ------------------------------------------------------- async def is_subscribed(self) -> bool: diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 439d7e2..0462b07 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -1519,6 +1519,10 @@ async def refresh(self) -> None: """Refresh ``recent_messages`` from the API.""" ... + async def get_participants(self) -> list[Author]: + """Return unique non-bot, non-self authors who've posted in the thread.""" + ... + def create_sent_message_from_message(self, message: Message) -> SentMessage: """Wrap a Message as a SentMessage with edit/delete capabilities.""" ... diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 4bb8766..5d0f77f 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -1282,6 +1282,45 @@ async def test_should_allow_posting_to_dm_thread(self): assert any(tid == "slack:DU123456:" and content == "Hello via DM!" for tid, content in adapter._post_calls) +# ============================================================================ +# thread() factory +# ============================================================================ + + +class TestThreadFactory: + """describe("thread") — chat.thread(id) factory for building a Thread handle.""" + + # TS: "should return a Thread handle for a valid thread ID" + async def test_should_return_a_thread_handle_for_a_valid_thread_id(self): + chat, adapter, state = await _init_chat() + thread = chat.thread("slack:C123:1234.5678") + assert thread is not None + assert thread.id == "slack:C123:1234.5678" + + # TS: "should allow posting to a thread handle" + async def test_should_allow_posting_to_a_thread_handle(self): + chat, adapter, state = await _init_chat() + thread = chat.thread("slack:C123:1234.5678") + await thread.post("Hello from outside a webhook!") + + assert any( + tid == "slack:C123:1234.5678" and content == "Hello from outside a webhook!" + for tid, content in adapter._post_calls + ) + + # TS: "should throw for an invalid thread ID" + async def test_should_throw_for_an_invalid_thread_id(self): + chat, adapter, state = await _init_chat() + with pytest.raises(ChatError, match="Invalid thread ID"): + chat.thread("") + + # TS: "should throw for an unknown adapter prefix" + async def test_should_throw_for_an_unknown_adapter_prefix(self): + chat, adapter, state = await _init_chat() + with pytest.raises(ChatError, match=r'Adapter "unknown" not found'): + chat.thread("unknown:C123:1234.5678") + + # ============================================================================ # 14. isDM (tests 55-57) # ============================================================================ diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 533daac..adb02f7 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -2540,6 +2540,151 @@ def test_work_with_different_adapters(self): assert channel_id == "gchat:spaces/ABC123" +# =========================================================================== +# getParticipants +# =========================================================================== + + +class TestGetParticipants: + """describe("getParticipants")""" + + # it("should return unique non-bot authors from messages") + @pytest.mark.asyncio + async def test_should_return_unique_nonbot_authors_from_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + msg1 = create_test_message( + "1", + "Hello", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + msg2 = create_test_message( + "2", + "Hi", + author=Author(user_id="U2", user_name="bob", full_name="Bob", is_bot=False, is_me=False), + ) + msg3 = create_test_message( + "3", + "Hello again", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[msg1, msg2, msg3], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 2 + user_ids = [p.user_id for p in participants] + assert "U1" in user_ids + assert "U2" in user_ids + + # it("should exclude bot messages") + @pytest.mark.asyncio + async def test_should_exclude_bot_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + human_msg = create_test_message( + "1", + "Hello", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + self_bot_msg = create_test_message( + "2", + "Hi there!", + author=Author(user_id="B1", user_name="bot", full_name="Bot", is_bot=True, is_me=True), + ) + third_party_bot_msg = create_test_message( + "3", + "Notification", + author=Author(user_id="B2", user_name="jira-bot", full_name="Jira Bot", is_bot=True, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[human_msg, self_bot_msg, third_party_bot_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 1 + assert participants[0].user_id == "U1" + + # Python-only: isolates the is_me=True, is_bot=False exclusion path + # (addresses PR #64 bot-review nit — prior test mixes is_bot and is_me). + @pytest.mark.asyncio + async def test_should_exclude_self_messages_even_when_not_bot(self): + adapter = create_mock_adapter() + state = create_mock_state() + + self_msg = create_test_message( + "1", + "my msg", + author=Author(user_id="U_SELF", user_name="me", full_name="Me", is_bot=False, is_me=True), + ) + other_msg = create_test_message( + "2", + "hi", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[self_msg, other_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert [p.user_id for p in participants] == ["U1"] + + # it("should return empty array for thread with only bot messages") + @pytest.mark.asyncio + async def test_should_return_empty_array_for_thread_with_only_bot_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + bot_msg = create_test_message( + "1", + "Bot message", + author=Author(user_id="B1", user_name="bot", full_name="Bot", is_bot=True, is_me=True), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[bot_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 0 + + # it("should include currentMessage author") + @pytest.mark.asyncio + async def test_should_include_currentmessage_author(self): + adapter = create_mock_adapter() + state = create_mock_state() + + current_msg = create_test_message( + "1", + "Hey bot", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[], next_cursor=None) + ) + + thread = _make_thread(adapter, state, current_message=current_msg) + participants = await thread.get_participants() + + assert len(participants) == 1 + assert participants[0].user_id == "U1" + + class TestMissingAbsorbers: """Fidelity-check absorbers for TS test names that have no Python equivalent."""