From da2e36ec2fd4e50cc8b27d475d732b3cfe89900f Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 9 Sep 2026 14:37:00 +0200 Subject: [PATCH 1/3] feat: add skip_truncate to channel delete Soft deleting a channel truncates it, so a channel recreated with the same id comes back empty. Channel.delete takes skip_truncate and sends it as a query param when set. Client.delete_channels already forwards options, so the batch endpoint needs no change. Co-Authored-By: Claude Opus 5 --- docs/channels/channel_management/deleting.md | 11 +++++++ stream_chat/async_chat/channel.py | 9 ++++-- stream_chat/base/channel.py | 5 ++- stream_chat/channel.py | 7 +++-- stream_chat/tests/test_channel_delete.py | 32 ++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 stream_chat/tests/test_channel_delete.py diff --git a/docs/channels/channel_management/deleting.md b/docs/channels/channel_management/deleting.md index 3eb2f5d..66fc938 100644 --- a/docs/channels/channel_management/deleting.md +++ b/docs/channels/channel_management/deleting.md @@ -12,6 +12,17 @@ channel.delete() > If you recreate this channel, it will show up empty. Recovering old messages is not supported. Use the disable method if you want a reversible change. +### Keeping the messages + +Pass `skip_truncate=True` to keep the messages of a soft deleted channel, so recreating the channel with the same id restores its history. It cannot be combined with a hard delete, and only distinct channels are eligible. + +```python +channel.delete(skip_truncate=True) + +# same option on the batch endpoint +response = client.delete_channels([cid1, cid2], skip_truncate=True) +``` + ## Deleting Many Channels You can delete up to 100 channels and optionally all of their messages using this method. This can be a large amount of data to delete, so this endpoint processes asynchronously, meaning responses contain a `task ID` which can be polled using the [getTask endpoint](/chat/docs/python#tasks-gettask) to check status of the deletions. Channels will be soft-deleted immediately so that channels no longer return from queries, but permanently deleting the channel and deleting messages takes longer to process. diff --git a/stream_chat/async_chat/channel.py b/stream_chat/async_chat/channel.py index 0f0fcf7..5b2708d 100644 --- a/stream_chat/async_chat/channel.py +++ b/stream_chat/async_chat/channel.py @@ -85,8 +85,13 @@ async def update_partial( payload = {"set": to_set or {}, "unset": to_unset or []} return await self.client.patch(self.url, data=payload) - async def delete(self, hard: bool = False) -> StreamResponse: - return await self.client.delete(self.url, {"hard_delete": hard}) + async def delete( + self, hard: bool = False, skip_truncate: bool = False + ) -> StreamResponse: + params = {"hard_delete": hard} + if skip_truncate: + params["skip_truncate"] = True + return await self.client.delete(self.url, params) async def truncate(self, **options: Any) -> StreamResponse: return await self.client.post(f"{self.url}/truncate", data=options) diff --git a/stream_chat/base/channel.py b/stream_chat/base/channel.py index c6287f8..24f4aed 100644 --- a/stream_chat/base/channel.py +++ b/stream_chat/base/channel.py @@ -166,11 +166,14 @@ def update_partial( @abc.abstractmethod def delete( - self, hard: bool = False + self, hard: bool = False, skip_truncate: bool = False ) -> Union[StreamResponse, Awaitable[StreamResponse]]: """ Delete the channel. Messages are permanently removed. + :param hard: hard delete the channel and its messages + :param skip_truncate: keep the messages of a soft deleted channel, so + recreating it with the same id restores the history :return: The server response """ pass diff --git a/stream_chat/channel.py b/stream_chat/channel.py index b3400b4..b3006d9 100644 --- a/stream_chat/channel.py +++ b/stream_chat/channel.py @@ -83,8 +83,11 @@ def update_partial( payload = {"set": to_set or {}, "unset": to_unset or []} return self.client.patch(self.url, data=payload) - def delete(self, hard: bool = False) -> StreamResponse: - return self.client.delete(self.url, params={"hard_delete": hard}) + def delete(self, hard: bool = False, skip_truncate: bool = False) -> StreamResponse: + params = {"hard_delete": hard} + if skip_truncate: + params["skip_truncate"] = True + return self.client.delete(self.url, params=params) def truncate(self, **options: Any) -> StreamResponse: return self.client.post(f"{self.url}/truncate", data=options) diff --git a/stream_chat/tests/test_channel_delete.py b/stream_chat/tests/test_channel_delete.py new file mode 100644 index 0000000..74fd2a5 --- /dev/null +++ b/stream_chat/tests/test_channel_delete.py @@ -0,0 +1,32 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from stream_chat.async_chat.channel import Channel as AsyncChannel +from stream_chat.channel import Channel + + +def test_delete_omits_skip_truncate_by_default() -> None: + client = MagicMock() + Channel(client, "messaging", "chan").delete() + client.delete.assert_called_once_with( + "channels/messaging/chan", params={"hard_delete": False} + ) + + +def test_delete_sends_skip_truncate() -> None: + client = MagicMock() + Channel(client, "messaging", "chan").delete(skip_truncate=True) + client.delete.assert_called_once_with( + "channels/messaging/chan", params={"hard_delete": False, "skip_truncate": True} + ) + + +@pytest.mark.asyncio +async def test_async_delete_sends_skip_truncate() -> None: + client = MagicMock() + client.delete = AsyncMock() + await AsyncChannel(client, "messaging", "chan").delete(skip_truncate=True) + client.delete.assert_called_once_with( + "channels/messaging/chan", {"hard_delete": False, "skip_truncate": True} + ) From 52aa7438239ef7b7a7df98d97c67b58665941019 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 9 Sep 2026 15:47:20 +0200 Subject: [PATCH 2/3] test: cover the async delete default The async path had no test for skip_truncate being omitted, so sending it as false would have passed. Co-Authored-By: Claude Opus 5 --- stream_chat/tests/test_channel_delete.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stream_chat/tests/test_channel_delete.py b/stream_chat/tests/test_channel_delete.py index 74fd2a5..4fd0cc6 100644 --- a/stream_chat/tests/test_channel_delete.py +++ b/stream_chat/tests/test_channel_delete.py @@ -22,6 +22,16 @@ def test_delete_sends_skip_truncate() -> None: ) +@pytest.mark.asyncio +async def test_async_delete_omits_skip_truncate_by_default() -> None: + client = MagicMock() + client.delete = AsyncMock() + await AsyncChannel(client, "messaging", "chan").delete() + client.delete.assert_called_once_with( + "channels/messaging/chan", {"hard_delete": False} + ) + + @pytest.mark.asyncio async def test_async_delete_sends_skip_truncate() -> None: client = MagicMock() From 192a86a1f5798b9db10977d266fc8b93d2956f2d Mon Sep 17 00:00:00 2001 From: Yassine Ennebati <4570448+yaziine@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:18:46 +0400 Subject: [PATCH 3/3] Update stream_chat/base/channel.py --- stream_chat/base/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream_chat/base/channel.py b/stream_chat/base/channel.py index 24f4aed..0ab74d6 100644 --- a/stream_chat/base/channel.py +++ b/stream_chat/base/channel.py @@ -169,7 +169,7 @@ def delete( self, hard: bool = False, skip_truncate: bool = False ) -> Union[StreamResponse, Awaitable[StreamResponse]]: """ - Delete the channel. Messages are permanently removed. + Delete the channel. :param hard: hard delete the channel and its messages :param skip_truncate: keep the messages of a soft deleted channel, so