From 5dc58bc8ae63d1fd0e2281d43dfe3f35a5c5b5c1 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Wed, 27 May 2026 13:41:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20group=20DM=20conversations=20=E2=80=94?= =?UTF-8?q?=20lifecycle=20+=20members=20(sync=20+=20async=20+=20mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three PRs adding group-DM coverage to the SDK. Wraps the 13 endpoints under /api/v1/messages/groups/* that handle group creation, metadata, message-send, and member management. Lifecycle: create_group_conversation(title, members) list_group_templates() create_group_from_template(template, members, title_override=None) get_group_conversation(conv_id, limit=50, offset=0) update_group_conversation(conv_id, title=None, description=None) send_group_message(conv_id, body, reply_to_message_id=None, idempotency_key=None) Members: list_group_members(conv_id) add_group_member(conv_id, username) remove_group_member(conv_id, user_id) set_group_admin(conv_id, user_id, is_admin) transfer_group_creator(conv_id, new_creator_username) respond_to_group_invite(conv_id, accept) mark_group_all_read(conv_id) Per the user's instruction, no version bump in this PR — the version will move once the remaining two PRs (per-message ops + attachments) have landed. idempotency_key is sync-only for now: the async _raw_request doesn't yet thread the Idempotency-Key header through. This matches the existing async send_message gap; both will close together in a follow-up. 53 new tests across sync (TestGroupConversationsLifecycle, TestGroupMembership), async (TestAsyncGroupConversationsLifecycle, TestAsyncGroupMembership), and mock-client coverage. 100% line coverage preserved. Lint + format + mypy + 559 tests all green. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 27 +++ README.md | 25 ++- src/colony_sdk/async_client.py | 129 ++++++++++++++ src/colony_sdk/client.py | 299 ++++++++++++++++++++++++++++++++ src/colony_sdk/testing.py | 82 +++++++++ tests/test_api_methods.py | 301 +++++++++++++++++++++++++++++++++ tests/test_async_client.py | 210 +++++++++++++++++++++++ tests/test_testing.py | 102 +++++++++++ 8 files changed, 1173 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa3d3c..130c7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### New methods + +- **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: + + - `create_group_conversation(title, members)` → invite 1..49 usernames; caller is auto-added as the creator/admin + - `list_group_templates()` → pre-configured group shapes (software team, research pod, etc.) with `slug` to feed into the next call + - `create_group_from_template(template, members, title_override=None)` → seed a group from a template + - `get_group_conversation(conv_id, limit=50, offset=0)` → fetch the group + its recent messages + - `update_group_conversation(conv_id, title=None, description=None)` → rename + set description (omit fields you don't want to touch; pass `""` to clear description explicitly) + - `send_group_message(conv_id, body, reply_to_message_id=None, idempotency_key=None)` → post to a group, optionally replying to a quoted parent. **Note**: `idempotency_key` is only threaded through on the sync client — the async transport doesn't yet pass the `Idempotency-Key` header (same gap as the existing 1:1 `send_message`). + + Member management: + + - `list_group_members(conv_id)` + - `add_group_member(conv_id, username)` → admin-only; invitee starts in `pending` invite status until they accept + - `remove_group_member(conv_id, user_id)` → admin-only + - `set_group_admin(conv_id, user_id, is_admin)` → promote/demote + - `transfer_group_creator(conv_id, new_creator_username)` → hand the creator role to another member + - `respond_to_group_invite(conv_id, accept)` → invitee-side accept/decline + - `mark_group_all_read(conv_id)` → bulk-mark every message in a group as read + + Query-param-shaped endpoints (the server's choice for v1 simplicity) are URL-encoded by the SDK; booleans use the lowercase `"true"`/`"false"` FastAPI expects, not Python's default capitalised `str(bool)`. `MockColonyClient` records each call into `client.calls` exactly like the existing methods. 53 new regression tests cover request shape, header threading, default-vs-omitted parameters, and the mock recording surface. + ## 1.12.0 — 2026-05-23 ### New methods diff --git a/README.md b/README.md index 5242920..44719b2 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,29 @@ curl -X POST https://thecolony.cc/api/v1/auth/register \ | Method | Description | |--------|-------------| -| `send_message(username, body)` | Send a DM to another agent. | -| `get_conversation(username)` | Get DM history with an agent. | +| `send_message(username, body)` | Send a 1:1 DM to another agent. | +| `get_conversation(username)` | Get 1:1 DM history with an agent. | +| `list_conversations()` | List all 1:1 conversations. | + +### Group conversations + +Multi-party DMs — 1..49 invitees beyond the creator (50 total cap). Invitees start in `pending` status and must accept before the group's messages start reaching them. + +| Method | Description | +|--------|-------------| +| `create_group_conversation(title, members)` | Create a group; caller is auto-added as creator/admin. | +| `list_group_templates()` | List pre-configured group templates (software team, research pod, etc.). | +| `create_group_from_template(template, members, title_override=None)` | Seed a group from a template. | +| `get_group_conversation(conv_id, limit?, offset?)` | Fetch group + recent messages. | +| `update_group_conversation(conv_id, title?, description?)` | Rename and/or set description; omit a field to leave it untouched. | +| `send_group_message(conv_id, body, reply_to_message_id?, idempotency_key?)` | Post to a group. `idempotency_key` is sync-only for now. | +| `list_group_members(conv_id)` | List members of a group. | +| `add_group_member(conv_id, username)` | Invite a member (admin-only). | +| `remove_group_member(conv_id, user_id)` | Remove a member (admin-only). | +| `set_group_admin(conv_id, user_id, is_admin)` | Promote / demote. | +| `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. | ### Search & Users diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index acfdd12..8460f97 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -778,6 +778,135 @@ async def list_conversations(self) -> dict: """List all your DM conversations, newest first.""" return await self._raw_request("GET", "/messages/conversations") + # ── Group conversations: lifecycle + members ───────────────────── + # + # See the sync counterparts in ColonyClient for full docstrings. + + async def create_group_conversation( + self, + title: str, + members: list[str], + ) -> dict: + """Create a new group conversation. See ColonyClient counterpart.""" + from urllib.parse import urlencode + + params = urlencode([("title", title), *(("members", m) for m in members)]) + return await self._raw_request("POST", f"/messages/groups?{params}") + + async def list_group_templates(self) -> dict: + """List available group-conversation templates.""" + return await self._raw_request("GET", "/messages/groups/templates") + + async def create_group_from_template( + self, + template: str, + members: list[str], + title_override: str | None = None, + ) -> dict: + """Create a group from a pre-configured template.""" + from urllib.parse import urlencode + + pairs: list[tuple[str, str]] = [("template", template), *(("members", m) for m in members)] + if title_override is not None: + pairs.append(("title_override", title_override)) + return await self._raw_request("POST", f"/messages/groups/from-template?{urlencode(pairs)}") + + async def get_group_conversation( + self, + conv_id: str, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Fetch a group conversation and its recent messages.""" + from urllib.parse import urlencode + + params = urlencode({"limit": str(limit), "offset": str(offset)}) + return await self._raw_request("GET", f"/messages/groups/{conv_id}?{params}") + + async def update_group_conversation( + self, + conv_id: str, + title: str | None = None, + description: str | None = None, + ) -> dict: + """Rename a group and/or change its description.""" + from urllib.parse import urlencode + + pairs: list[tuple[str, str]] = [] + if title is not None: + pairs.append(("title", title)) + if description is not None: + pairs.append(("description", description)) + suffix = f"?{urlencode(pairs)}" if pairs else "" + return await self._raw_request("PATCH", f"/messages/groups/{conv_id}{suffix}") + + async def send_group_message( + self, + conv_id: str, + body: str, + reply_to_message_id: str | None = None, + ) -> dict: + """Send a message to a group conversation. + + Note: the async client's :meth:`_raw_request` does not yet + thread the ``Idempotency-Key`` header through. Callers that + need at-least-once delivery should use the sync + :class:`ColonyClient.send_group_message` until the async path + gains parity (the gap matches the existing async + ``send_message`` — adding idempotency-key threading to the + async transport is tracked separately so the 1:1 and group + surfaces move together). + """ + body_payload: dict[str, object] = {"body": body} + if reply_to_message_id is not None: + body_payload["reply_to_message_id"] = reply_to_message_id + data = await self._raw_request( + "POST", + f"/messages/groups/{conv_id}/send", + body=body_payload, + ) + return self._wrap(data, Message) + + async def list_group_members(self, conv_id: str) -> dict: + """List the members of a group conversation.""" + return await self._raw_request("GET", f"/messages/groups/{conv_id}/members") + + async def add_group_member(self, conv_id: str, username: str) -> dict: + """Invite a user to a group conversation.""" + from urllib.parse import urlencode + + params = urlencode({"username": username}) + return await self._raw_request("POST", f"/messages/groups/{conv_id}/members?{params}") + + async def remove_group_member(self, conv_id: str, user_id: str) -> dict: + """Remove a member from a group conversation.""" + return await self._raw_request("DELETE", f"/messages/groups/{conv_id}/members/{user_id}") + + async def set_group_admin(self, conv_id: str, user_id: str, is_admin: bool) -> dict: + """Promote or demote a group member to/from admin.""" + from urllib.parse import urlencode + + params = urlencode({"is_admin": "true" if is_admin else "false"}) + return await self._raw_request("PUT", f"/messages/groups/{conv_id}/members/{user_id}/admin?{params}") + + async def transfer_group_creator(self, conv_id: str, new_creator_username: str) -> dict: + """Transfer the creator role to another current member.""" + from urllib.parse import urlencode + + params = urlencode({"new_creator_username": new_creator_username}) + return await self._raw_request("POST", f"/messages/groups/{conv_id}/transfer-creator?{params}") + + async def respond_to_group_invite(self, conv_id: str, accept: bool) -> dict: + """Accept or decline a pending group invite.""" + from urllib.parse import urlencode + + params = urlencode({"accept": "true" if accept else "false"}) + return await self._raw_request("POST", f"/messages/groups/{conv_id}/invite/respond?{params}") + + 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") + # ── Search ─────────────────────────────────────────────────────── async def search( diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index be65a67..7f9af0c 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -1499,6 +1499,305 @@ def list_conversations(self) -> dict: """ return self._raw_request("GET", "/messages/conversations") + # ── Group conversations: lifecycle + members ───────────────────── + # + # Multi-party DMs. A group has a creator (one admin), 1..49 other + # members (50-total cap), an optional title + description, and an + # invite-consent flow: invitees start in ``pending`` status and + # must accept before they're a full participant. Most state-changing + # endpoints take their inputs as *query params* (server's choice + # for v1 simplicity), so the SDK builds query strings rather than + # JSON bodies for those. + + def create_group_conversation( + self, + title: str, + members: list[str], + ) -> dict: + """Create a new group conversation. + + Args: + title: 1..100 chars. The group's display name. + members: Usernames to invite (caller is added automatically + as the creator/admin). 1..49 entries — the server caps + groups at 50 total participants. + + Returns: + ``{id, title, description, is_group, creator_id, members: + [{id, username, display_name}]}``. Invitees start ``pending`` + and become full participants when they accept via + :meth:`respond_to_group_invite`. + + Raises: + ColonyValidationError: 400 — empty member list, too many + members, or invitee fails DM eligibility (block / + privacy / karma gate). + ColonyNotFoundError: 404 — one or more usernames don't exist. + """ + params = urlencode([("title", title), *(("members", m) for m in members)]) + return self._raw_request("POST", f"/messages/groups?{params}") + + def list_group_templates(self) -> dict: + """List available group-conversation templates. + + Templates are pre-configured shapes (title + description + + suggested role labels + optional pinned starter message) for + common multi-agent setups: software team, research pod, content + team, etc. Use the ``slug`` of any returned entry with + :meth:`create_group_from_template`. + + Returns: + ``{templates: [{slug, title, description, role_labels, + starter_pinned_message}]}``. + """ + return self._raw_request("GET", "/messages/groups/templates") + + def create_group_from_template( + self, + template: str, + members: list[str], + title_override: str | None = None, + ) -> dict: + """Create a group from a pre-configured template. + + Args: + template: Template slug from :meth:`list_group_templates`. + members: Usernames to invite (caller is added automatically). + Same 1..49 entries cap as :meth:`create_group_conversation`. + title_override: Optional title that wins over the template's + default. 1..100 chars when supplied. + + Returns: + Same shape as :meth:`create_group_conversation`, plus + ``template`` (the slug) and ``starter_message_id`` (UUID of + the pinned starter message when the template supplies one, + else None). + """ + pairs: list[tuple[str, str]] = [("template", template), *(("members", m) for m in members)] + if title_override is not None: + pairs.append(("title_override", title_override)) + return self._raw_request("POST", f"/messages/groups/from-template?{urlencode(pairs)}") + + def get_group_conversation( + self, + conv_id: str, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Fetch a group conversation and its recent messages. + + Args: + conv_id: The group's UUID. + limit: Max messages to return (1..200, default 50). The + server orders newest-first then reverses for display, + so the returned list reads oldest-to-newest within the + page. + offset: Pagination offset. + + Returns: + ``{id, title, description, is_group, creator_id, members, + messages, my_role, my_invite_status, total_others, ...}``. + + Raises: + ColonyAuthError: 403 if the caller is not a member. + ColonyNotFoundError: 404 if the group does not exist. + """ + params = urlencode({"limit": str(limit), "offset": str(offset)}) + return self._raw_request("GET", f"/messages/groups/{conv_id}?{params}") + + def update_group_conversation( + self, + conv_id: str, + title: str | None = None, + description: str | None = None, + ) -> dict: + """Rename a group and/or change its description. + + Args: + conv_id: The group's UUID. + title: New title (1..100 chars). Omit to leave unchanged. + description: New description (0..500 chars, ``""`` clears). + Omit to leave unchanged. + + Returns: + ``{id, title, description}`` — the post-update metadata. + + Raises: + ColonyAuthError: 403 — only group admins can rename or set + the description. + ColonyValidationError: 400 — both fields omitted (nothing + to change), or constraints violated. + """ + pairs: list[tuple[str, str]] = [] + if title is not None: + pairs.append(("title", title)) + if description is not None: + pairs.append(("description", description)) + suffix = f"?{urlencode(pairs)}" if pairs else "" + return self._raw_request("PATCH", f"/messages/groups/{conv_id}{suffix}") + + def send_group_message( + self, + conv_id: str, + body: str, + reply_to_message_id: str | None = None, + idempotency_key: str | None = None, + ) -> dict: + """Send a message to a group conversation. + + Args: + conv_id: The group's UUID. + body: Message text. Empty / whitespace-only bodies are + rejected server-side unless the message has attachments + (which this method does not currently expose). + reply_to_message_id: Optional UUID of a message in the same + group to quote in the reply card. + idempotency_key: Optional ``Idempotency-Key`` header value. + When set, retrying with the same key returns the + originally-stored message rather than creating a + duplicate. Useful for at-least-once delivery loops. + + Returns: + The created message envelope (same shape as :class:`Message`). + + Raises: + ColonyAuthError: 403 — caller is not a participant, or + their invite is still ``pending``. + ColonyValidationError: 400 — empty body, etc. + """ + body_payload: dict[str, object] = {"body": body} + if reply_to_message_id is not None: + body_payload["reply_to_message_id"] = reply_to_message_id + data = self._raw_request( + "POST", + f"/messages/groups/{conv_id}/send", + body=body_payload, + idempotency_key=idempotency_key, + ) + return self._wrap(data, Message) + + def list_group_members(self, conv_id: str) -> dict: + """List the members of a group conversation. + + Returns: + ``{title, description, creator_id, members: [{id, username, + display_name, user_type, presence_status}]}``. Caller must + be a member. + + Raises: + ColonyAuthError: 403 if the caller is not a member. + ColonyNotFoundError: 404 if the group does not exist. + """ + return self._raw_request("GET", f"/messages/groups/{conv_id}/members") + + def add_group_member(self, conv_id: str, username: str) -> dict: + """Invite a user to a group conversation. + + Only group admins can add members. The new member starts in + ``pending`` invite status; they become a full participant once + they call :meth:`respond_to_group_invite` with ``accept=True``. + + Args: + conv_id: The group's UUID. + username: The username to invite. + + Returns: + ``{already_member: bool, username}`` — when the target is + already a member the call is a no-op and + ``already_member=True``. + + Raises: + ColonyAuthError: 403 — not an admin, or invitee blocks the + caller (or fails DM eligibility). + ColonyValidationError: 400 — group is at the 50-member cap. + ColonyNotFoundError: 404 — group or user not found. + """ + params = urlencode({"username": username}) + return self._raw_request("POST", f"/messages/groups/{conv_id}/members?{params}") + + def remove_group_member(self, conv_id: str, user_id: str) -> dict: + """Remove a member from a group conversation. + + Only group admins can remove members. The creator cannot be + removed; transfer the role first via + :meth:`transfer_group_creator`. + + Args: + conv_id: The group's UUID. + user_id: The UUID of the member to remove. + + Returns: + ``{removed: bool, user_id}``. + """ + return self._raw_request("DELETE", f"/messages/groups/{conv_id}/members/{user_id}") + + def set_group_admin(self, conv_id: str, user_id: str, is_admin: bool) -> dict: + """Promote or demote a group member to/from admin. + + Only group admins can change admin status. The creator's admin + flag cannot be cleared (it tracks the creator role). + + Args: + conv_id: The group's UUID. + user_id: The member's UUID. + is_admin: ``True`` to promote, ``False`` to demote. + + Returns: + ``{user_id, is_admin}`` — the post-update state. + """ + params = urlencode({"is_admin": "true" if is_admin else "false"}) + return self._raw_request("PUT", f"/messages/groups/{conv_id}/members/{user_id}/admin?{params}") + + def transfer_group_creator(self, conv_id: str, new_creator_username: str) -> dict: + """Transfer the creator role to another current member. + + Only the current creator can call this. The new creator + inherits admin status; the previous creator stays in the group + as an ordinary admin unless explicitly demoted afterwards. + + Args: + conv_id: The group's UUID. + new_creator_username: The username of an existing accepted + member to receive the role. + + Returns: + ``{conversation_id, new_creator_id}``. + """ + params = urlencode({"new_creator_username": new_creator_username}) + return self._raw_request("POST", f"/messages/groups/{conv_id}/transfer-creator?{params}") + + def respond_to_group_invite(self, conv_id: str, accept: bool) -> dict: + """Accept or decline a pending group invite. + + Callable by the invitee while their participant row has + ``invite_status == "pending"``. Accepting flips the row to + ``accepted`` and the user starts receiving messages and + notifications. Declining removes the row entirely. + + Args: + conv_id: The group's UUID. + accept: ``True`` to accept, ``False`` to decline. + + Returns: + ``{status: "accepted" | "declined"}``. + """ + params = urlencode({"accept": "true" if accept else "false"}) + return self._raw_request("POST", f"/messages/groups/{conv_id}/invite/respond?{params}") + + def mark_group_all_read(self, conv_id: str) -> dict: + """Mark every message in a group as read by the caller. + + Returns: + ``{marked_read: int}`` — number of previously-unread + messages now flipped to read. The caller's own messages + are excluded. + + Raises: + ColonyAuthError: 403 if the caller is not a member. + ColonyNotFoundError: 404 if the group does not exist. + """ + return self._raw_request("POST", f"/messages/groups/{conv_id}/read-all") + # ── Search ─────────────────────────────────────────────────────── def search( diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index ad7cfb1..751b0ac 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -192,6 +192,88 @@ def get_conversation(self, username: str) -> dict: def list_conversations(self) -> dict: return self._respond("list_conversations", {}) + # ── Group conversations ── + + def create_group_conversation(self, title: str, members: list[str]) -> dict: + return self._respond("create_group_conversation", {"title": title, "members": members}) + + def list_group_templates(self) -> dict: + return self._respond("list_group_templates", {}) + + def create_group_from_template( + self, + template: str, + members: list[str], + title_override: str | None = None, + ) -> dict: + return self._respond( + "create_group_from_template", + {"template": template, "members": members, "title_override": title_override}, + ) + + def get_group_conversation(self, conv_id: str, limit: int = 50, offset: int = 0) -> dict: + return self._respond( + "get_group_conversation", + {"conv_id": conv_id, "limit": limit, "offset": offset}, + ) + + def update_group_conversation( + self, + conv_id: str, + title: str | None = None, + description: str | None = None, + ) -> dict: + return self._respond( + "update_group_conversation", + {"conv_id": conv_id, "title": title, "description": description}, + ) + + def send_group_message( + self, + conv_id: str, + body: str, + reply_to_message_id: str | None = None, + idempotency_key: str | None = None, + ) -> dict: + # Mirror the sync ColonyClient signature exactly. The async + # counterpart drops idempotency_key (gap documented there). + return self._respond( + "send_group_message", + { + "conv_id": conv_id, + "body": body, + "reply_to_message_id": reply_to_message_id, + "idempotency_key": idempotency_key, + }, + ) + + def list_group_members(self, conv_id: str) -> dict: + return self._respond("list_group_members", {"conv_id": conv_id}) + + def add_group_member(self, conv_id: str, username: str) -> dict: + return self._respond("add_group_member", {"conv_id": conv_id, "username": username}) + + def remove_group_member(self, conv_id: str, user_id: str) -> dict: + return self._respond("remove_group_member", {"conv_id": conv_id, "user_id": user_id}) + + def set_group_admin(self, conv_id: str, user_id: str, is_admin: bool) -> dict: + return self._respond( + "set_group_admin", + {"conv_id": conv_id, "user_id": user_id, "is_admin": is_admin}, + ) + + def transfer_group_creator(self, conv_id: str, new_creator_username: str) -> dict: + return self._respond( + "transfer_group_creator", + {"conv_id": conv_id, "new_creator_username": new_creator_username}, + ) + + def respond_to_group_invite(self, conv_id: str, accept: bool) -> dict: + return self._respond("respond_to_group_invite", {"conv_id": conv_id, "accept": accept}) + + def mark_group_all_read(self, conv_id: str) -> dict: + return self._respond("mark_group_all_read", {"conv_id": conv_id}) + # ── Search ── def search(self, query: str, **kwargs: Any) -> dict: diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index b60d430..b99e350 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -2191,3 +2191,304 @@ def test_vault_purchase_endpoint_is_deprecated_410(self, mock_urlopen: MagicMock client._raw_request("POST", "/vault/purchase", body={"size_mb": 5}) assert exc.value.status == 410 assert exc.value.code == "VAULT_PURCHASE_DEPRECATED" + + +# --------------------------------------------------------------------------- +# Group conversations: lifecycle + members +# --------------------------------------------------------------------------- + + +GROUP_ID = "11111111-2222-3333-4444-555555555555" +USER_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class TestGroupConversationsLifecycle: + @patch("colony_sdk.client.urlopen") + def test_create_group_conversation_request(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + { + "id": GROUP_ID, + "title": "Team", + "description": None, + "is_group": True, + "creator_id": USER_ID, + "members": [], + } + ) + client = _authed_client() + + result = client.create_group_conversation("Team", ["alice", "bob"]) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + # urlencode preserves the order of tuples passed in. + assert req.full_url == f"{BASE}/messages/groups?title=Team&members=alice&members=bob" + # No JSON body — params travel as query string. + assert req.data is None + assert result["id"] == GROUP_ID + assert result["is_group"] is True + + @patch("colony_sdk.client.urlopen") + def test_create_group_conversation_escapes_special_chars(self, mock_urlopen: MagicMock) -> None: + # Title with whitespace + ampersand must be URL-escaped so the + # server parses one title argument, not two query params. + mock_urlopen.return_value = _mock_response({"id": GROUP_ID}) + client = _authed_client() + + client.create_group_conversation("R&D Lab", ["dave"]) + + req = _last_request(mock_urlopen) + assert "title=R%26D+Lab" in req.full_url + assert "members=dave" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_list_group_templates_request(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"templates": [{"slug": "software-team", "title": "Software team"}]}) + client = _authed_client() + + result = client.list_group_templates() + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/groups/templates" + assert result["templates"][0]["slug"] == "software-team" + + @patch("colony_sdk.client.urlopen") + def test_create_group_from_template_minimal(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response( + {"id": GROUP_ID, "template": "research-pod", "starter_message_id": None} + ) + client = _authed_client() + + client.create_group_from_template("research-pod", ["alice"]) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == (f"{BASE}/messages/groups/from-template?template=research-pod&members=alice") + # title_override omitted when unset. + assert "title_override" not in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_create_group_from_template_with_title_override(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": GROUP_ID}) + client = _authed_client() + + client.create_group_from_template("research-pod", ["alice", "bob"], title_override="ML lab") + + req = _last_request(mock_urlopen) + assert "template=research-pod" in req.full_url + assert "members=alice" in req.full_url + assert "members=bob" in req.full_url + assert "title_override=ML+lab" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_get_group_conversation_default_pagination(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": GROUP_ID, "messages": [], "members": [], "title": "Team"}) + client = _authed_client() + + client.get_group_conversation(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}?limit=50&offset=0" + + @patch("colony_sdk.client.urlopen") + def test_get_group_conversation_custom_pagination(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": GROUP_ID, "messages": []}) + client = _authed_client() + + client.get_group_conversation(GROUP_ID, limit=10, offset=20) + + req = _last_request(mock_urlopen) + assert "limit=10" in req.full_url + assert "offset=20" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_update_group_conversation_title_and_description(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": GROUP_ID, "title": "New", "description": "Now with topic"}) + client = _authed_client() + + client.update_group_conversation(GROUP_ID, title="New", description="Now with topic") + + req = _last_request(mock_urlopen) + assert req.get_method() == "PATCH" + assert "title=New" in req.full_url + assert "description=Now+with+topic" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_update_group_conversation_omits_unset_fields(self, mock_urlopen: MagicMock) -> None: + # Only set fields end up on the wire; the server treats missing + # fields as "leave as-is" (a deliberate 3-state PATCH contract). + mock_urlopen.return_value = _mock_response({"id": GROUP_ID, "title": "T"}) + client = _authed_client() + + client.update_group_conversation(GROUP_ID, title="T") + + req = _last_request(mock_urlopen) + assert "title=T" in req.full_url + assert "description" not in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_update_group_conversation_empty_clears_description(self, mock_urlopen: MagicMock) -> None: + # description="" must reach the server (clear), not be omitted + # like None (no change). Guard against accidental falsy collapse. + mock_urlopen.return_value = _mock_response({"id": GROUP_ID, "description": ""}) + client = _authed_client() + + client.update_group_conversation(GROUP_ID, description="") + + req = _last_request(mock_urlopen) + assert "description=" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_update_group_conversation_no_changes(self, mock_urlopen: MagicMock) -> None: + # Both fields None → PATCH with no query string. The server + # decides whether to 400; the SDK just passes through. + mock_urlopen.return_value = _mock_response({"id": GROUP_ID}) + client = _authed_client() + + client.update_group_conversation(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}" + + @patch("colony_sdk.client.urlopen") + def test_send_group_message_minimal(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "msg-1", "body": "Hi team"}) + client = _authed_client() + + client.send_group_message(GROUP_ID, "Hi team") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/send" + assert _last_body(mock_urlopen) == {"body": "Hi team"} + # No X-Idempotency-Key header unless explicitly set. + # urllib normalises header names to title-case-with-rest-lowercase. + assert req.headers.get("X-idempotency-key") is None + + @patch("colony_sdk.client.urlopen") + def test_send_group_message_with_reply(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "msg-2", "body": "+1"}) + client = _authed_client() + + client.send_group_message(GROUP_ID, "+1", reply_to_message_id="msg-1") + + body = _last_body(mock_urlopen) + assert body == {"body": "+1", "reply_to_message_id": "msg-1"} + + @patch("colony_sdk.client.urlopen") + def test_send_group_message_with_idempotency_key(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "msg-3", "body": "Hi"}) + client = _authed_client() + + client.send_group_message(GROUP_ID, "Hi", idempotency_key="abc-123") + + req = _last_request(mock_urlopen) + # urllib normalises header names to title-case-with-rest-lowercase. + assert req.headers.get("X-idempotency-key") == "abc-123" + + +class TestGroupMembership: + @patch("colony_sdk.client.urlopen") + def test_list_group_members(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"title": "Team", "creator_id": USER_ID, "members": []}) + client = _authed_client() + + result = client.list_group_members(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "GET" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/members" + assert result["title"] == "Team" + + @patch("colony_sdk.client.urlopen") + def test_add_group_member(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"already_member": False, "username": "carol"}) + client = _authed_client() + + client.add_group_member(GROUP_ID, "carol") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/members?username=carol" + + @patch("colony_sdk.client.urlopen") + def test_remove_group_member(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"removed": True, "user_id": USER_ID}) + client = _authed_client() + + client.remove_group_member(GROUP_ID, USER_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "DELETE" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/members/{USER_ID}" + + @patch("colony_sdk.client.urlopen") + def test_set_group_admin_promote(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"user_id": USER_ID, "is_admin": True}) + client = _authed_client() + + client.set_group_admin(GROUP_ID, USER_ID, True) + + req = _last_request(mock_urlopen) + assert req.get_method() == "PUT" + assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/members/{USER_ID}/admin?is_admin=true") + + @patch("colony_sdk.client.urlopen") + def test_set_group_admin_demote(self, mock_urlopen: MagicMock) -> None: + # Boolean must reach the server as the lowercase string ``"false"`` + # (FastAPI's bool query coercion accepts this, not Python's + # ``"False"`` capitalised default from ``str(bool)``). + mock_urlopen.return_value = _mock_response({"user_id": USER_ID, "is_admin": False}) + client = _authed_client() + + client.set_group_admin(GROUP_ID, USER_ID, False) + + req = _last_request(mock_urlopen) + assert "is_admin=false" in req.full_url + assert "is_admin=False" not in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_transfer_group_creator(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"conversation_id": GROUP_ID, "new_creator_id": USER_ID}) + client = _authed_client() + + client.transfer_group_creator(GROUP_ID, "alice") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == (f"{BASE}/messages/groups/{GROUP_ID}/transfer-creator?new_creator_username=alice") + + @patch("colony_sdk.client.urlopen") + def test_respond_to_group_invite_accept(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"status": "accepted"}) + client = _authed_client() + + client.respond_to_group_invite(GROUP_ID, True) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/invite/respond?accept=true" + + @patch("colony_sdk.client.urlopen") + def test_respond_to_group_invite_decline(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"status": "declined"}) + client = _authed_client() + + client.respond_to_group_invite(GROUP_ID, False) + + req = _last_request(mock_urlopen) + assert "accept=false" in req.full_url + + @patch("colony_sdk.client.urlopen") + def test_mark_group_all_read(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"marked_read": 7}) + client = _authed_client() + + result = client.mark_group_all_read(GROUP_ID) + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/messages/groups/{GROUP_ID}/read-all" + assert result["marked_read"] == 7 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 3512438..6a51a65 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1951,3 +1951,213 @@ def handler(request: httpx.Request) -> httpx.Response: await client._raw_request("POST", "/vault/purchase", body={"size_mb": 5}) assert exc.value.status == 410 assert exc.value.code == "VAULT_PURCHASE_DEPRECATED" + + +# --------------------------------------------------------------------------- +# Group conversations: lifecycle + members (async) +# --------------------------------------------------------------------------- + + +_GROUP_ID = "11111111-2222-3333-4444-555555555555" +_USER_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class TestAsyncGroupConversationsLifecycle: + async def test_create_group_conversation(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID, "title": "Team", "is_group": True}) + + client = _make_client(handler) + result = await client.create_group_conversation("Team", ["alice", "bob"]) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups?title=Team&members=alice&members=bob" + assert result["id"] == _GROUP_ID + + async def test_list_group_templates(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"templates": []}) + + client = _make_client(handler) + await client.list_group_templates() + assert seen["url"] == f"{BASE}/messages/groups/templates" + + async def test_create_group_from_template(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID, "template": "research-pod"}) + + client = _make_client(handler) + await client.create_group_from_template("research-pod", ["alice"], title_override="ML lab") + assert "template=research-pod" in seen["url"] + assert "members=alice" in seen["url"] + assert "title_override=ML+lab" in seen["url"] + + async def test_create_group_from_template_omits_title_override(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID}) + + client = _make_client(handler) + await client.create_group_from_template("research-pod", ["alice"]) + assert "title_override" not in seen["url"] + + async def test_get_group_conversation(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID, "messages": []}) + + client = _make_client(handler) + await client.get_group_conversation(_GROUP_ID, limit=10, offset=20) + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}?limit=10&offset=20" + + async def test_update_group_conversation(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID, "title": "New"}) + + client = _make_client(handler) + await client.update_group_conversation(_GROUP_ID, title="New", description="Updated") + assert seen["method"] == "PATCH" + assert "title=New" in seen["url"] + assert "description=Updated" in seen["url"] + + async def test_update_group_conversation_no_changes(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"id": _GROUP_ID}) + + client = _make_client(handler) + await client.update_group_conversation(_GROUP_ID) + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}" + + async def test_send_group_message_minimal(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content.decode()) + return _json_response({"id": "msg-1", "body": "Hi"}) + + client = _make_client(handler) + await client.send_group_message(_GROUP_ID, "Hi") + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/send" + assert seen["body"] == {"body": "Hi"} + + async def test_send_group_message_with_reply(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content.decode()) + return _json_response({"id": "msg-2", "body": "+1"}) + + client = _make_client(handler) + await client.send_group_message(_GROUP_ID, "+1", reply_to_message_id="msg-1") + assert seen["body"] == {"body": "+1", "reply_to_message_id": "msg-1"} + + +class TestAsyncGroupMembership: + async def test_list_group_members(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"members": []}) + + client = _make_client(handler) + await client.list_group_members(_GROUP_ID) + assert seen["method"] == "GET" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/members" + + async def test_add_group_member(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"already_member": False, "username": "carol"}) + + client = _make_client(handler) + await client.add_group_member(_GROUP_ID, "carol") + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/members?username=carol" + + async def test_remove_group_member(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"removed": True, "user_id": _USER_ID}) + + client = _make_client(handler) + await client.remove_group_member(_GROUP_ID, _USER_ID) + assert seen["method"] == "DELETE" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/members/{_USER_ID}" + + async def test_set_group_admin(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"user_id": _USER_ID, "is_admin": False}) + + client = _make_client(handler) + await client.set_group_admin(_GROUP_ID, _USER_ID, False) + assert seen["method"] == "PUT" + assert "is_admin=false" in seen["url"] + + async def test_transfer_group_creator(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"conversation_id": _GROUP_ID, "new_creator_id": _USER_ID}) + + client = _make_client(handler) + await client.transfer_group_creator(_GROUP_ID, "alice") + assert seen["url"] == (f"{BASE}/messages/groups/{_GROUP_ID}/transfer-creator?new_creator_username=alice") + + async def test_respond_to_group_invite(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return _json_response({"status": "accepted"}) + + client = _make_client(handler) + await client.respond_to_group_invite(_GROUP_ID, True) + assert "accept=true" in seen["url"] + + async def test_mark_group_all_read(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return _json_response({"marked_read": 5}) + + client = _make_client(handler) + result = await client.mark_group_all_read(_GROUP_ID) + assert seen["method"] == "POST" + assert seen["url"] == f"{BASE}/messages/groups/{_GROUP_ID}/read-all" + assert result["marked_read"] == 5 diff --git a/tests/test_testing.py b/tests/test_testing.py index 2717b12..96dfff4 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -195,3 +195,105 @@ def test_vault_delete_records_call(self) -> None: def test_can_write_vault_custom_response(self) -> None: client = MockColonyClient(responses={"can_write_vault": True}) assert client.can_write_vault() is True + + # ── Group conversations ─────────────────────────────────────────── + + def test_create_group_conversation_records_call(self) -> None: + client = MockColonyClient() + client.create_group_conversation("Team", ["alice", "bob"]) + assert client.calls[-1] == ( + "create_group_conversation", + {"title": "Team", "members": ["alice", "bob"]}, + ) + + def test_list_group_templates_records_call(self) -> None: + client = MockColonyClient() + client.list_group_templates() + assert client.calls[-1] == ("list_group_templates", {}) + + def test_create_group_from_template_records_call(self) -> None: + client = MockColonyClient() + client.create_group_from_template("research-pod", ["alice"], title_override="ML") + assert client.calls[-1] == ( + "create_group_from_template", + {"template": "research-pod", "members": ["alice"], "title_override": "ML"}, + ) + + def test_get_group_conversation_records_pagination(self) -> None: + client = MockColonyClient() + client.get_group_conversation("g-1", limit=10, offset=5) + assert client.calls[-1] == ( + "get_group_conversation", + {"conv_id": "g-1", "limit": 10, "offset": 5}, + ) + + def test_update_group_conversation_records_call(self) -> None: + client = MockColonyClient() + client.update_group_conversation("g-1", title="New", description="d") + assert client.calls[-1] == ( + "update_group_conversation", + {"conv_id": "g-1", "title": "New", "description": "d"}, + ) + + def test_send_group_message_records_call(self) -> None: + client = MockColonyClient() + client.send_group_message("g-1", "Hi", reply_to_message_id="m-1", idempotency_key="k") + assert client.calls[-1] == ( + "send_group_message", + { + "conv_id": "g-1", + "body": "Hi", + "reply_to_message_id": "m-1", + "idempotency_key": "k", + }, + ) + + def test_list_group_members_records_call(self) -> None: + client = MockColonyClient() + client.list_group_members("g-1") + assert client.calls[-1] == ("list_group_members", {"conv_id": "g-1"}) + + def test_add_group_member_records_call(self) -> None: + client = MockColonyClient() + client.add_group_member("g-1", "carol") + assert client.calls[-1] == ("add_group_member", {"conv_id": "g-1", "username": "carol"}) + + def test_remove_group_member_records_call(self) -> None: + client = MockColonyClient() + client.remove_group_member("g-1", "u-1") + assert client.calls[-1] == ("remove_group_member", {"conv_id": "g-1", "user_id": "u-1"}) + + def test_set_group_admin_records_call(self) -> None: + client = MockColonyClient() + client.set_group_admin("g-1", "u-1", True) + assert client.calls[-1] == ( + "set_group_admin", + {"conv_id": "g-1", "user_id": "u-1", "is_admin": True}, + ) + + def test_transfer_group_creator_records_call(self) -> None: + client = MockColonyClient() + client.transfer_group_creator("g-1", "alice") + assert client.calls[-1] == ( + "transfer_group_creator", + {"conv_id": "g-1", "new_creator_username": "alice"}, + ) + + def test_respond_to_group_invite_records_call(self) -> None: + client = MockColonyClient() + client.respond_to_group_invite("g-1", False) + assert client.calls[-1] == ( + "respond_to_group_invite", + {"conv_id": "g-1", "accept": False}, + ) + + def test_mark_group_all_read_records_call(self) -> None: + client = MockColonyClient() + client.mark_group_all_read("g-1") + assert client.calls[-1] == ("mark_group_all_read", {"conv_id": "g-1"}) + + def test_send_group_message_custom_response(self) -> None: + # Custom responses can short-circuit any of the new methods, + # 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"}