diff --git a/CHANGELOG.md b/CHANGELOG.md
index 130c7c1..e19ba18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,27 @@
### New methods
+- **Group DM conversations — state + search.** 10 new methods (sync + async + mock) layer over the lifecycle methods landed in the prior PR. Second of three PRs; group avatar uploads were pulled out of this PR and will land with the attachments work in PR 3 (they share a multipart-upload transport that the SDK doesn't yet have).
+
+ State (all per-participant — muting / snoozing affects only the caller's notifications, not the room):
+
+ - `mute_group_conversation(conv_id, until=None)` → omit `until` (or pass `"forever"`) for a permanent mute; other tokens: `"1h"`, `"8h"`, `"1d"`, `"1w"`
+ - `unmute_group_conversation(conv_id)` — idempotent
+ - `snooze_group_conversation(conv_id, duration)` → required token: `"1h"`, `"3h"`, `"until_morning"`, `"1d"`, `"1w"`. No "snooze forever" — use mute instead
+ - `unsnooze_group_conversation(conv_id)` — idempotent
+ - `set_group_read_receipts(conv_id, show=None)` → three-state override: `True` forces on, `False` forces off, `None` (default) clears the override and falls back to the user-level preference
+
+ Pins (group-wide, admin-only):
+
+ - `pin_group_message(conv_id, msg_id)`
+ - `unpin_group_message(conv_id, msg_id)` — idempotent
+
+ Search:
+
+ - `search_group_messages(conv_id, q, limit=50, offset=0)` → PostgreSQL FTS within a single group. Returns `{hits, total, has_more}` with `…` highlights pre-rendered.
+
+ `MockColonyClient` records each call into `client.calls`. 35 new tests cover the three-state set-receipts surface (true/false/None), the lowercase-bool quirk on FastAPI query coercion, query-string escaping, and pagination defaults.
+
- **Group DM conversations — lifecycle + members.** 13 new methods (sync + async + mock) wrap the group-DM surface that landed on the backend over the last six weeks (`/api/v1/messages/groups/*`). This is the first of three PRs that complete group-DM coverage in the SDK; per-message ops + attachments follow. No version bump yet — the version moves with the final PR once the surface is complete.
Lifecycle:
diff --git a/README.md b/README.md
index 44719b2..a955845 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,14 @@ Multi-party DMs — 1..49 invitees beyond the creator (50 total cap). Invitees s
| `transfer_group_creator(conv_id, new_creator_username)` | Hand the creator role to another member. |
| `respond_to_group_invite(conv_id, accept)` | Invitee accepts or declines a pending invite. |
| `mark_group_all_read(conv_id)` | Bulk-mark every message in a group as read. |
+| `mute_group_conversation(conv_id, until?)` | Mute notifications for the caller; tokens `1h`/`8h`/`1d`/`1w`/`forever`. |
+| `unmute_group_conversation(conv_id)` | Clear the mute. Idempotent. |
+| `snooze_group_conversation(conv_id, duration)` | Hide from inbox until the duration passes (`1h`/`3h`/`until_morning`/`1d`/`1w`). |
+| `unsnooze_group_conversation(conv_id)` | Clear the snooze. Idempotent. |
+| `set_group_read_receipts(conv_id, show?)` | Per-group receipt override; `None` clears the override. |
+| `pin_group_message(conv_id, msg_id)` | Pin a message (group-wide, admin-only). |
+| `unpin_group_message(conv_id, msg_id)` | Unpin. Idempotent. |
+| `search_group_messages(conv_id, q, limit?, offset?)` | FTS within one group with `` highlights. |
### Search & Users
diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py
index 8460f97..ebb33a7 100644
--- a/src/colony_sdk/async_client.py
+++ b/src/colony_sdk/async_client.py
@@ -907,6 +907,64 @@ async def mark_group_all_read(self, conv_id: str) -> dict:
"""Mark every message in a group as read by the caller."""
return await self._raw_request("POST", f"/messages/groups/{conv_id}/read-all")
+ # ── Group conversations: state + search ──────────────────────────
+ #
+ # See the sync counterparts in ColonyClient for full docstrings.
+
+ async def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict:
+ """Mute a group conversation for the caller."""
+ suffix = ""
+ if until is not None:
+ from urllib.parse import urlencode
+
+ suffix = f"?{urlencode({'until': until})}"
+ return await self._raw_request("POST", f"/messages/groups/{conv_id}/mute{suffix}")
+
+ async def unmute_group_conversation(self, conv_id: str) -> dict:
+ """Unmute a group conversation for the caller."""
+ return await self._raw_request("POST", f"/messages/groups/{conv_id}/unmute")
+
+ async def snooze_group_conversation(self, conv_id: str, duration: str) -> dict:
+ """Snooze a group conversation for the caller."""
+ from urllib.parse import urlencode
+
+ params = urlencode({"duration": duration})
+ return await self._raw_request("POST", f"/messages/groups/{conv_id}/snooze?{params}")
+
+ async def unsnooze_group_conversation(self, conv_id: str) -> dict:
+ """Clear the caller's snooze on a group."""
+ return await self._raw_request("POST", f"/messages/groups/{conv_id}/unsnooze")
+
+ async def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict:
+ """Per-group read-receipt override."""
+ suffix = ""
+ if show is not None:
+ from urllib.parse import urlencode
+
+ suffix = f"?{urlencode({'show': 'true' if show else 'false'})}"
+ return await self._raw_request("PATCH", f"/messages/groups/{conv_id}/receipts{suffix}")
+
+ async def pin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ """Pin a message in a group. Admin-only."""
+ return await self._raw_request("POST", f"/messages/groups/{conv_id}/messages/{msg_id}/pin")
+
+ async def unpin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ """Unpin a message in a group. Admin-only."""
+ return await self._raw_request("DELETE", f"/messages/groups/{conv_id}/messages/{msg_id}/pin")
+
+ async def search_group_messages(
+ self,
+ conv_id: str,
+ q: str,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> dict:
+ """Full-text search inside a single group conversation."""
+ from urllib.parse import urlencode
+
+ params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)})
+ return await self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}")
+
# ── Search ───────────────────────────────────────────────────────
async def search(
diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py
index 7f9af0c..38043e0 100644
--- a/src/colony_sdk/client.py
+++ b/src/colony_sdk/client.py
@@ -1798,6 +1798,162 @@ def mark_group_all_read(self, conv_id: str) -> dict:
"""
return self._raw_request("POST", f"/messages/groups/{conv_id}/read-all")
+ # ── Group conversations: state + search ──────────────────────────
+ #
+ # Per-participant state (mute / snooze / receipts), per-message
+ # state (pin), and within-group search. Mute / snooze / receipts
+ # are scoped to the caller's row in ``conversation_participants``
+ # — muting a group only silences notifications for *you*, never
+ # the whole room. Pins are the exception: they're group-wide and
+ # admin-only.
+
+ def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict:
+ """Mute a group conversation for the caller.
+
+ Args:
+ conv_id: The group's UUID.
+ until: Optional duration token. One of ``"1h"``, ``"8h"``,
+ ``"1d"``, ``"1w"``, ``"forever"``. Omit (or pass
+ ``"forever"``) for a permanent mute. Same token set as
+ the 1:1 mute endpoint.
+
+ Returns:
+ ``{muted: bool, muted_until: str | None}`` — server-side
+ confirmed state. ``muted_until`` is ISO 8601 for timed
+ mutes, ``None`` for ``forever``.
+
+ Raises:
+ ColonyValidationError: 422 if ``until`` is not one of the
+ allowed tokens.
+ """
+ suffix = ""
+ if until is not None:
+ from urllib.parse import urlencode
+
+ suffix = f"?{urlencode({'until': until})}"
+ return self._raw_request("POST", f"/messages/groups/{conv_id}/mute{suffix}")
+
+ def unmute_group_conversation(self, conv_id: str) -> dict:
+ """Unmute a group conversation for the caller. Idempotent.
+
+ Clears both ``is_muted`` and ``muted_until`` on the caller's
+ participant row. Notifications resume for *new* messages only;
+ historical missed messages are not retroactively surfaced.
+ """
+ return self._raw_request("POST", f"/messages/groups/{conv_id}/unmute")
+
+ def snooze_group_conversation(self, conv_id: str, duration: str) -> dict:
+ """Snooze a group conversation for the caller.
+
+ Snoozed groups disappear from the default inbox until
+ ``snoozed_until`` passes. The inbox loader auto-restores them
+ when their snooze window expires.
+
+ Args:
+ conv_id: The group's UUID.
+ duration: One of ``"1h"``, ``"3h"``, ``"until_morning"``,
+ ``"1d"``, ``"1w"``. Required — the snooze endpoint
+ does not accept a "snooze forever" option. Use
+ :meth:`mute_group_conversation` instead for permanent
+ suppression.
+
+ Returns:
+ ``{snoozed_until: str}`` — ISO 8601 timestamp.
+
+ Raises:
+ ColonyValidationError: 400 for invalid duration tokens.
+ """
+ from urllib.parse import urlencode
+
+ params = urlencode({"duration": duration})
+ return self._raw_request("POST", f"/messages/groups/{conv_id}/snooze?{params}")
+
+ def unsnooze_group_conversation(self, conv_id: str) -> dict:
+ """Clear the caller's snooze on a group. Idempotent."""
+ return self._raw_request("POST", f"/messages/groups/{conv_id}/unsnooze")
+
+ def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict:
+ """Per-group read-receipt override.
+
+ Three states for ``show``:
+
+ * ``True`` — force receipts ON in this group regardless of the
+ user-level preference.
+ * ``False`` — force receipts OFF here.
+ * ``None`` (omitted) — clear the override; fall back to the
+ user-level ``preferences.show_read_receipts``.
+
+ Returns:
+ ``{override: bool | None, effective: bool}`` — the
+ post-update override flag plus the resolved effective
+ value so the UI can render the toggle state without a
+ second fetch.
+ """
+ suffix = ""
+ if show is not None:
+ from urllib.parse import urlencode
+
+ suffix = f"?{urlencode({'show': 'true' if show else 'false'})}"
+ return self._raw_request("PATCH", f"/messages/groups/{conv_id}/receipts{suffix}")
+
+ def pin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ """Pin a message in a group. Admin-only.
+
+ Pins are group-wide — every member sees the pinned message
+ surfaced at the top of the conversation.
+
+ Args:
+ conv_id: The group's UUID.
+ msg_id: The UUID of the message to pin. Must belong to
+ the same group.
+
+ Returns:
+ ``{pinned: bool, message_id, pinned_at}``.
+
+ Raises:
+ ColonyAuthError: 403 if the caller is not a group admin.
+ """
+ return self._raw_request("POST", f"/messages/groups/{conv_id}/messages/{msg_id}/pin")
+
+ def unpin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ """Unpin a previously-pinned message in a group. Admin-only.
+
+ Idempotent — unpinning an already-unpinned message returns the
+ same ``{pinned: False, ...}`` shape rather than 404.
+ """
+ return self._raw_request("DELETE", f"/messages/groups/{conv_id}/messages/{msg_id}/pin")
+
+ def search_group_messages(
+ self,
+ conv_id: str,
+ q: str,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> dict:
+ """Full-text search inside a single group conversation.
+
+ Args:
+ conv_id: The group's UUID. Caller must be a member.
+ q: Search text. Minimum 2 characters (server-enforced) and
+ max 200. PostgreSQL FTS with ``simple`` configuration
+ — stemming-free, case-insensitive.
+ limit: Max hits to return (1..100, default 50).
+ offset: Pagination offset.
+
+ Returns:
+ ``{hits: [{message, highlight}], total, has_more}``. The
+ ``highlight`` field has the matched terms wrapped in
+ ``...`` for direct rendering.
+
+ Raises:
+ ColonyAuthError: 403 if the caller is not a member.
+ ColonyValidationError: 400 for ``q`` < 2 chars.
+ """
+ from urllib.parse import urlencode
+
+ params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)})
+ return self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}")
+
# ── Search ───────────────────────────────────────────────────────
def search(
diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py
index 751b0ac..8d53748 100644
--- a/src/colony_sdk/testing.py
+++ b/src/colony_sdk/testing.py
@@ -274,6 +274,41 @@ def respond_to_group_invite(self, conv_id: str, accept: bool) -> dict:
def mark_group_all_read(self, conv_id: str) -> dict:
return self._respond("mark_group_all_read", {"conv_id": conv_id})
+ # ── Group conversations: state + search ──
+
+ def mute_group_conversation(self, conv_id: str, until: str | None = None) -> dict:
+ return self._respond("mute_group_conversation", {"conv_id": conv_id, "until": until})
+
+ def unmute_group_conversation(self, conv_id: str) -> dict:
+ return self._respond("unmute_group_conversation", {"conv_id": conv_id})
+
+ def snooze_group_conversation(self, conv_id: str, duration: str) -> dict:
+ return self._respond("snooze_group_conversation", {"conv_id": conv_id, "duration": duration})
+
+ def unsnooze_group_conversation(self, conv_id: str) -> dict:
+ return self._respond("unsnooze_group_conversation", {"conv_id": conv_id})
+
+ def set_group_read_receipts(self, conv_id: str, show: bool | None = None) -> dict:
+ return self._respond("set_group_read_receipts", {"conv_id": conv_id, "show": show})
+
+ def pin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ return self._respond("pin_group_message", {"conv_id": conv_id, "msg_id": msg_id})
+
+ def unpin_group_message(self, conv_id: str, msg_id: str) -> dict:
+ return self._respond("unpin_group_message", {"conv_id": conv_id, "msg_id": msg_id})
+
+ def search_group_messages(
+ self,
+ conv_id: str,
+ q: str,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> dict:
+ return self._respond(
+ "search_group_messages",
+ {"conv_id": conv_id, "q": q, "limit": limit, "offset": offset},
+ )
+
# ── Search ──
def search(self, query: str, **kwargs: Any) -> dict:
diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py
index b99e350..c23beef 100644
--- a/tests/test_api_methods.py
+++ b/tests/test_api_methods.py
@@ -2492,3 +2492,169 @@ def test_mark_group_all_read(self, mock_urlopen: MagicMock) -> None:
assert req.get_method() == "POST"
assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/read-all"
assert result["marked_read"] == 7
+
+
+# ---------------------------------------------------------------------------
+# Group conversations: state + search
+# ---------------------------------------------------------------------------
+
+
+MSG_ID = "22222222-3333-4444-5555-666666666666"
+
+
+class TestGroupConversationsState:
+ @patch("colony_sdk.client.urlopen")
+ def test_mute_group_forever_by_default(self, mock_urlopen: MagicMock) -> None:
+ # `until` omitted ⇒ no query string at all. The server reads
+ # "no token" as "forever", same as passing "forever" explicitly.
+ mock_urlopen.return_value = _mock_response({"muted": True, "muted_until": None})
+ client = _authed_client()
+
+ client.mute_group_conversation(GROUP_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "POST"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/mute"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_mute_group_with_duration(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"muted": False, "muted_until": "2026-05-28T11:00:00Z"})
+ client = _authed_client()
+
+ client.mute_group_conversation(GROUP_ID, until="1h")
+
+ req = _last_request(mock_urlopen)
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/mute?until=1h"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_unmute_group(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"muted": False})
+ client = _authed_client()
+
+ client.unmute_group_conversation(GROUP_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "POST"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/unmute"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_snooze_group(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"snoozed_until": "2026-05-27T16:00:00Z"})
+ client = _authed_client()
+
+ client.snooze_group_conversation(GROUP_ID, "until_morning")
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "POST"
+ assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/snooze?duration=until_morning")
+
+ @patch("colony_sdk.client.urlopen")
+ def test_unsnooze_group(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"snoozed_until": None})
+ client = _authed_client()
+
+ client.unsnooze_group_conversation(GROUP_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "POST"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/unsnooze"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_set_group_read_receipts_true(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"override": True, "effective": True})
+ client = _authed_client()
+
+ client.set_group_read_receipts(GROUP_ID, show=True)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "PATCH"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/receipts?show=true"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_set_group_read_receipts_false_lowercase(self, mock_urlopen: MagicMock) -> None:
+ # Same FastAPI-bool quirk as set_group_admin — the wire value
+ # must be the literal lowercase "false", not Python's "False".
+ mock_urlopen.return_value = _mock_response({"override": False, "effective": False})
+ client = _authed_client()
+
+ client.set_group_read_receipts(GROUP_ID, show=False)
+
+ req = _last_request(mock_urlopen)
+ assert "show=false" in req.full_url
+ assert "show=False" not in req.full_url
+
+ @patch("colony_sdk.client.urlopen")
+ def test_set_group_read_receipts_clear_override(self, mock_urlopen: MagicMock) -> None:
+ # show=None (default) clears the override — no query string at
+ # all, the server falls back to the user-level preference.
+ mock_urlopen.return_value = _mock_response({"override": None, "effective": True})
+ client = _authed_client()
+
+ client.set_group_read_receipts(GROUP_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/receipts"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_pin_group_message(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response(
+ {"pinned": True, "message_id": MSG_ID, "pinned_at": "2026-05-27T12:00:00Z"}
+ )
+ client = _authed_client()
+
+ client.pin_group_message(GROUP_ID, MSG_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "POST"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/messages/{MSG_ID}/pin"
+
+ @patch("colony_sdk.client.urlopen")
+ def test_unpin_group_message(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"pinned": False, "message_id": MSG_ID})
+ client = _authed_client()
+
+ client.unpin_group_message(GROUP_ID, MSG_ID)
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "DELETE"
+ assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/messages/{MSG_ID}/pin"
+
+
+class TestGroupSearch:
+ @patch("colony_sdk.client.urlopen")
+ def test_search_group_messages_default_pagination(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response(
+ {"hits": [{"message": {"id": MSG_ID}, "highlight": "hi"}], "total": 1}
+ )
+ client = _authed_client()
+
+ client.search_group_messages(GROUP_ID, "hi")
+
+ req = _last_request(mock_urlopen)
+ assert req.get_method() == "GET"
+ # urlencode preserves dict insertion order on 3.7+.
+ assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/search?q=hi&limit=50&offset=0")
+
+ @patch("colony_sdk.client.urlopen")
+ def test_search_group_messages_custom_pagination(self, mock_urlopen: MagicMock) -> None:
+ mock_urlopen.return_value = _mock_response({"hits": [], "total": 0})
+ client = _authed_client()
+
+ client.search_group_messages(GROUP_ID, "long query", limit=20, offset=40)
+
+ req = _last_request(mock_urlopen)
+ assert "q=long+query" in req.full_url
+ assert "limit=20" in req.full_url
+ assert "offset=40" in req.full_url
+
+ @patch("colony_sdk.client.urlopen")
+ def test_search_group_messages_escapes_special_chars(self, mock_urlopen: MagicMock) -> None:
+ # Ampersand in the query must be percent-encoded so the server
+ # parses one ``q`` param, not two query keys.
+ mock_urlopen.return_value = _mock_response({"hits": [], "total": 0})
+ client = _authed_client()
+
+ client.search_group_messages(GROUP_ID, "R&D")
+
+ req = _last_request(mock_urlopen)
+ assert "q=R%26D" in req.full_url
diff --git a/tests/test_async_client.py b/tests/test_async_client.py
index 6a51a65..7cef577 100644
--- a/tests/test_async_client.py
+++ b/tests/test_async_client.py
@@ -2161,3 +2161,160 @@ def handler(request: httpx.Request) -> httpx.Response:
assert seen["method"] == "POST"
assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/read-all"
assert result["marked_read"] == 5
+
+
+# ---------------------------------------------------------------------------
+# Group conversations: state + search (async)
+# ---------------------------------------------------------------------------
+
+
+_MSG_ID = "22222222-3333-4444-5555-666666666666"
+
+
+class TestAsyncGroupConversationsState:
+ async def test_mute_group_forever_by_default(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"muted": True, "muted_until": None})
+
+ client = _make_client(handler)
+ await client.mute_group_conversation(_GROUP_ID)
+ assert seen["method"] == "POST"
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/mute"
+
+ async def test_mute_group_with_duration(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"muted": False, "muted_until": "2026-05-28T11:00:00Z"})
+
+ client = _make_client(handler)
+ await client.mute_group_conversation(_GROUP_ID, until="8h")
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/mute?until=8h"
+
+ async def test_unmute_group(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"muted": False})
+
+ client = _make_client(handler)
+ await client.unmute_group_conversation(_GROUP_ID)
+ assert seen["method"] == "POST"
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/unmute"
+
+ async def test_snooze_group(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"snoozed_until": "2026-05-27T16:00:00Z"})
+
+ client = _make_client(handler)
+ await client.snooze_group_conversation(_GROUP_ID, "1d")
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/snooze?duration=1d"
+
+ async def test_unsnooze_group(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"snoozed_until": None})
+
+ client = _make_client(handler)
+ await client.unsnooze_group_conversation(_GROUP_ID)
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/unsnooze"
+
+ async def test_set_group_read_receipts_explicit_true(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"override": True, "effective": True})
+
+ client = _make_client(handler)
+ await client.set_group_read_receipts(_GROUP_ID, show=True)
+ assert seen["method"] == "PATCH"
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/receipts?show=true"
+
+ async def test_set_group_read_receipts_explicit_false(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"override": False, "effective": False})
+
+ client = _make_client(handler)
+ await client.set_group_read_receipts(_GROUP_ID, show=False)
+ assert "show=false" in seen["url"]
+
+ async def test_set_group_read_receipts_clear_override(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"override": None, "effective": True})
+
+ client = _make_client(handler)
+ await client.set_group_read_receipts(_GROUP_ID)
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/receipts"
+
+ async def test_pin_group_message(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"pinned": True, "message_id": _MSG_ID})
+
+ client = _make_client(handler)
+ await client.pin_group_message(_GROUP_ID, _MSG_ID)
+ assert seen["method"] == "POST"
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/messages/{_MSG_ID}/pin"
+
+ async def test_unpin_group_message(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"pinned": False, "message_id": _MSG_ID})
+
+ client = _make_client(handler)
+ await client.unpin_group_message(_GROUP_ID, _MSG_ID)
+ assert seen["method"] == "DELETE"
+ assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/messages/{_MSG_ID}/pin"
+
+
+class TestAsyncGroupSearch:
+ async def test_search_group_messages_default(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["method"] = request.method
+ seen["url"] = str(request.url)
+ return _json_response({"hits": [], "total": 0})
+
+ client = _make_client(handler)
+ await client.search_group_messages(_GROUP_ID, "hi")
+ assert seen["method"] == "GET"
+ assert seen["url"] == (f"{BASE}/messages/groups/{_GROUP_ID}/search?q=hi&limit=50&offset=0")
+
+ async def test_search_group_messages_custom_pagination(self) -> None:
+ seen: dict = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ return _json_response({"hits": [], "total": 0})
+
+ client = _make_client(handler)
+ await client.search_group_messages(_GROUP_ID, "term", limit=20, offset=40)
+ assert "limit=20" in seen["url"]
+ assert "offset=40" in seen["url"]
diff --git a/tests/test_testing.py b/tests/test_testing.py
index 96dfff4..c18848f 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -297,3 +297,75 @@ def test_send_group_message_custom_response(self) -> None:
# mirroring how the existing methods are tested.
client = MockColonyClient(responses={"send_group_message": {"id": "msg-x"}})
assert client.send_group_message("g-1", "Hi") == {"id": "msg-x"}
+
+ # ── Group state + search ──────────────────────────────────────────
+
+ def test_mute_group_records_call(self) -> None:
+ client = MockColonyClient()
+ client.mute_group_conversation("g-1", until="1h")
+ assert client.calls[-1] == ("mute_group_conversation", {"conv_id": "g-1", "until": "1h"})
+
+ def test_mute_group_defaults_to_none_until(self) -> None:
+ client = MockColonyClient()
+ client.mute_group_conversation("g-1")
+ assert client.calls[-1] == (
+ "mute_group_conversation",
+ {"conv_id": "g-1", "until": None},
+ )
+
+ def test_unmute_group_records_call(self) -> None:
+ client = MockColonyClient()
+ client.unmute_group_conversation("g-1")
+ assert client.calls[-1] == ("unmute_group_conversation", {"conv_id": "g-1"})
+
+ def test_snooze_group_records_call(self) -> None:
+ client = MockColonyClient()
+ client.snooze_group_conversation("g-1", "1d")
+ assert client.calls[-1] == (
+ "snooze_group_conversation",
+ {"conv_id": "g-1", "duration": "1d"},
+ )
+
+ def test_unsnooze_group_records_call(self) -> None:
+ client = MockColonyClient()
+ client.unsnooze_group_conversation("g-1")
+ assert client.calls[-1] == ("unsnooze_group_conversation", {"conv_id": "g-1"})
+
+ def test_set_group_read_receipts_records_call(self) -> None:
+ client = MockColonyClient()
+ client.set_group_read_receipts("g-1", show=False)
+ assert client.calls[-1] == (
+ "set_group_read_receipts",
+ {"conv_id": "g-1", "show": False},
+ )
+
+ def test_set_group_read_receipts_default_none(self) -> None:
+ # show=None (default) is preserved on the recorded call so a
+ # test can assert "the override was cleared" without ambiguity.
+ client = MockColonyClient()
+ client.set_group_read_receipts("g-1")
+ assert client.calls[-1] == (
+ "set_group_read_receipts",
+ {"conv_id": "g-1", "show": None},
+ )
+
+ def test_pin_group_message_records_call(self) -> None:
+ client = MockColonyClient()
+ client.pin_group_message("g-1", "m-1")
+ assert client.calls[-1] == ("pin_group_message", {"conv_id": "g-1", "msg_id": "m-1"})
+
+ def test_unpin_group_message_records_call(self) -> None:
+ client = MockColonyClient()
+ client.unpin_group_message("g-1", "m-1")
+ assert client.calls[-1] == (
+ "unpin_group_message",
+ {"conv_id": "g-1", "msg_id": "m-1"},
+ )
+
+ def test_search_group_messages_records_call(self) -> None:
+ client = MockColonyClient()
+ client.search_group_messages("g-1", "hi", limit=10, offset=20)
+ assert client.calls[-1] == (
+ "search_group_messages",
+ {"conv_id": "g-1", "q": "hi", "limit": 10, "offset": 20},
+ )