Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/chat_sdk/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 Compare is_bot explicitly against True

Author.is_bot is typed as bool | "unknown", but this check relies on truthiness, so authors with is_bot="unknown" are treated as bots and excluded from participants. That causes get_participants() to silently drop valid users in adapters that emit unknown bot status (for example Telegram fallback authors), so the method can return incomplete results even when non-self humans posted.

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.

Intentional upstream parity — in TS, !author.isBot also excludes the string "unknown" because non-empty strings are truthy in JS. Explicit is True would diverge from vercel/chat. See thread.ts:359-384.

):
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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The check msg.author.is_bot will evaluate to True if the value is the string "unknown", causing authors with unknown bot status to be excluded from the participants list. If the intention is to only exclude confirmed bots, consider using msg.author.is_bot is True. However, if this behavior is intended to maintain strict parity with the TypeScript SDK (where !author.isBot also excludes 'unknown'), then the current implementation is correct.

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.

Intentional upstream parity — in TS, !author.isBot on is_bot="unknown" also evaluates false (non-empty strings truthy). Python's if ... or author.is_bot: mirrors that. See upstream thread.ts:359-384.

continue
seen[msg.author.user_id] = msg.author

return list(seen.values())

# -- Subscriptions -------------------------------------------------------

async def is_subscribed(self) -> bool:
Expand Down
4 changes: 4 additions & 0 deletions src/chat_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
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 — this is the standard typing.Protocol method body per PEP 544. Consistent with every other Protocol method in this file.


def create_sent_message_from_message(self, message: Message) -> SentMessage:
"""Wrap a Message as a SentMessage with edit/delete capabilities."""
...
Expand Down
39 changes: 39 additions & 0 deletions tests/test_chat_faithful.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ============================================================================
Expand Down
145 changes: 145 additions & 0 deletions tests/test_thread_faithful.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading