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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mark>…</mark>` 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:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mark>` highlights. |

### Search & Users

Expand Down
58 changes: 58 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
156 changes: 156 additions & 0 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
``<mark>...</mark>`` 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(
Expand Down
35 changes: 35 additions & 0 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading