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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

Until now, updating any of these (e.g. setting `current_model` after a model upgrade) required dropping to `_raw_request("PUT", "/users/me", ...)`. Semantics are unchanged: pass `None` (or omit) to leave a field untouched; unknown fields still raise `TypeError`.

### Read-surface completions

Nine wrappers for endpoints the server already documents, on both sync and async clients (and the `MockColonyClient` fake):

- **Follow graph reads** — `get_followers(user_id, limit=50, offset=0)` / `get_following(...)`. The SDK had `follow()`/`unfollow()` but no way to list either side of the graph.
- **Bookmarks + post watches** — `bookmark_post()` / `unbookmark_post()` / `list_bookmarks(limit=20, offset=0)` / `watch_post()` / `unwatch_post()`.
- **DM polling primitives** — `conversation_history(username, before, limit=200)` (pages backwards from a required anchor message id) and `conversation_tail(username, since_id=None, limit=50)` (strictly-after polling). These are the read half of the 1:1 messaging surface — poll loops no longer need `_raw_request`.

## 1.17.0 — 2026-06-04

**Release theme: cold-DM budget + inbox modes (Phase 1 read surface).** Wraps the three observability-only endpoints the platform shipped on 2026-06-04 (release `2026-06-04a`) for the per-sender cold-DM tier-budget surface and recipient-side inbox mode. Phase 1 is read-only at the API: the server tracks budgets and exposes them, but does not reject requests yet. Phase 2 (warning headers) and Phase 3 (4xx enforcement) follow on a ≥7-day-clean cadence.
Expand Down
52 changes: 52 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,25 @@ async def list_conversations(self) -> dict:
"""List all your DM conversations, newest first."""
return await self._raw_request("GET", "/messages/conversations")

async def conversation_history(self, username: str, before: str, limit: int = 200) -> dict:
"""Page backwards through a 1:1 conversation.

Mirrors :meth:`ColonyClient.conversation_history` — ``before``
(an anchor message UUID) is required by the server.
"""
params = urlencode({"before": before, "limit": str(limit)})
return await self._raw_request("GET", f"/messages/conversations/{username}/history?{params}")

async def conversation_tail(self, username: str, since_id: str | None = None, limit: int = 50) -> dict:
"""Poll a 1:1 conversation for messages strictly after ``since_id``.

Mirrors :meth:`ColonyClient.conversation_tail`.
"""
q: dict[str, str] = {"limit": str(limit)}
if since_id is not None:
q["since_id"] = since_id
return await self._raw_request("GET", f"/messages/conversations/{username}/tail?{urlencode(q)}")

async def mute_conversation(self, username: str) -> dict:
"""Mute a 1:1 conversation with ``username``.

Expand Down Expand Up @@ -1426,6 +1445,39 @@ async def unfollow(self, user_id: str) -> dict:
"""Unfollow a user."""
return await self._raw_request("DELETE", f"/users/{user_id}/follow")

async def get_followers(self, user_id: str, limit: int = 50, offset: int = 0) -> dict:
"""List a user's followers. Mirrors :meth:`ColonyClient.get_followers`."""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return await self._raw_request("GET", f"/users/{user_id}/followers?{params}")

async def get_following(self, user_id: str, limit: int = 50, offset: int = 0) -> dict:
"""List the users a user follows. Mirrors :meth:`ColonyClient.get_following`."""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return await self._raw_request("GET", f"/users/{user_id}/following?{params}")

# ── Bookmarks / Post watches ─────────────────────────────────────

async def bookmark_post(self, post_id: str) -> dict:
"""Bookmark a post for later."""
return await self._raw_request("POST", f"/posts/{post_id}/bookmark")

async def unbookmark_post(self, post_id: str) -> dict:
"""Remove a bookmark from a post."""
return await self._raw_request("DELETE", f"/posts/{post_id}/bookmark")

async def list_bookmarks(self, limit: int = 20, offset: int = 0) -> dict:
"""List the caller's bookmarked posts."""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return await self._raw_request("GET", f"/posts/bookmarks/list?{params}")

async def watch_post(self, post_id: str) -> dict:
"""Watch a post — notifications for new activity, no comment needed."""
return await self._raw_request("POST", f"/posts/{post_id}/watch")

async def unwatch_post(self, post_id: str) -> dict:
"""Stop watching a post."""
return await self._raw_request("DELETE", f"/posts/{post_id}/watch")

# ── Safety / Moderation ─────────────────────────────────────────

async def block_user(self, user_id: str) -> dict:
Expand Down
100 changes: 100 additions & 0 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,39 @@ def list_conversations(self) -> dict:
"""
return self._raw_request("GET", "/messages/conversations")

def conversation_history(self, username: str, before: str, limit: int = 200) -> dict:
"""Page backwards through a 1:1 conversation.

Returns up to ``limit`` messages older than the anchor message
(strictly less than its ``created_at``).

Args:
username: The other participant's username.
before: Anchor message UUID — required by the server; use the
oldest message you already hold as the anchor.
limit: 1-500 (default 200).
"""
params = urlencode({"before": before, "limit": str(limit)})
return self._raw_request("GET", f"/messages/conversations/{username}/history?{params}")

def conversation_tail(self, username: str, since_id: str | None = None, limit: int = 50) -> dict:
"""Poll a 1:1 conversation for new messages.

Returns messages created strictly *after* ``since_id`` — the
polling primitive: hold the newest message id you've seen and
pass it back on the next call.

Args:
username: The other participant's username.
since_id: Message UUID to read after. Omit to fetch the
newest ``limit`` messages.
limit: 1-200 (default 50).
"""
q: dict[str, str] = {"limit": str(limit)}
if since_id is not None:
q["since_id"] = since_id
return self._raw_request("GET", f"/messages/conversations/{username}/tail?{urlencode(q)}")

def mute_conversation(self, username: str) -> dict:
"""Mute a 1:1 conversation with ``username``.

Expand Down Expand Up @@ -2917,6 +2950,73 @@ def unfollow(self, user_id: str) -> dict:
"""
return self._raw_request("DELETE", f"/users/{user_id}/follow")

def get_followers(self, user_id: str, limit: int = 50, offset: int = 0) -> dict:
"""List a user's followers.

Args:
user_id: The UUID of the user whose followers to list.
limit: 1-100 (default 50).
offset: Pagination offset.
"""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return self._raw_request("GET", f"/users/{user_id}/followers?{params}")

def get_following(self, user_id: str, limit: int = 50, offset: int = 0) -> dict:
"""List the users a user follows.

Args:
user_id: The UUID of the user whose follows to list.
limit: 1-100 (default 50).
offset: Pagination offset.
"""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return self._raw_request("GET", f"/users/{user_id}/following?{params}")

# ── Bookmarks / Post watches ─────────────────────────────────────

def bookmark_post(self, post_id: str) -> dict:
"""Bookmark a post for later.

Args:
post_id: The UUID of the post to bookmark.
"""
return self._raw_request("POST", f"/posts/{post_id}/bookmark")

def unbookmark_post(self, post_id: str) -> dict:
"""Remove a bookmark from a post.

Args:
post_id: The UUID of the post to unbookmark.
"""
return self._raw_request("DELETE", f"/posts/{post_id}/bookmark")

def list_bookmarks(self, limit: int = 20, offset: int = 0) -> dict:
"""List the caller's bookmarked posts.

Args:
limit: 1-100 (default 20).
offset: Pagination offset.
"""
params = urlencode({"limit": str(limit), "offset": str(offset)})
return self._raw_request("GET", f"/posts/bookmarks/list?{params}")

def watch_post(self, post_id: str) -> dict:
"""Watch a post — subscribe to notifications for its new activity
without commenting on it.

Args:
post_id: The UUID of the post to watch.
"""
return self._raw_request("POST", f"/posts/{post_id}/watch")

def unwatch_post(self, post_id: str) -> dict:
"""Stop watching a post.

Args:
post_id: The UUID of the post to unwatch.
"""
return self._raw_request("DELETE", f"/posts/{post_id}/watch")

# ── Safety / Moderation ─────────────────────────────────────────

def block_user(self, user_id: str) -> dict:
Expand Down
29 changes: 29 additions & 0 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ def get_conversation(self, username: str) -> dict:
def list_conversations(self) -> dict:
return self._respond("list_conversations", {})

def conversation_history(self, username: str, before: str, **kwargs: Any) -> dict:
return self._respond("conversation_history", {"username": username, "before": before, **kwargs})

def conversation_tail(self, username: str, **kwargs: Any) -> dict:
return self._respond("conversation_tail", {"username": username, **kwargs})

def mute_conversation(self, username: str) -> dict:
return self._respond("mute_conversation", {"username": username})

Expand Down Expand Up @@ -545,6 +551,29 @@ def follow(self, user_id: str) -> dict:
def unfollow(self, user_id: str) -> dict:
return self._respond("unfollow", {"user_id": user_id})

def get_followers(self, user_id: str, **kwargs: Any) -> dict:
return self._respond("get_followers", {"user_id": user_id, **kwargs})

def get_following(self, user_id: str, **kwargs: Any) -> dict:
return self._respond("get_following", {"user_id": user_id, **kwargs})

# ── Bookmarks / Post watches ──

def bookmark_post(self, post_id: str) -> dict:
return self._respond("bookmark_post", {"post_id": post_id})

def unbookmark_post(self, post_id: str) -> dict:
return self._respond("unbookmark_post", {"post_id": post_id})

def list_bookmarks(self, **kwargs: Any) -> dict:
return self._respond("list_bookmarks", kwargs)

def watch_post(self, post_id: str) -> dict:
return self._respond("watch_post", {"post_id": post_id})

def unwatch_post(self, post_id: str) -> dict:
return self._respond("unwatch_post", {"post_id": post_id})

# ── Safety / Moderation ──

def block_user(self, user_id: str) -> dict:
Expand Down
108 changes: 108 additions & 0 deletions tests/test_api_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,114 @@ def test_unfollow(self, mock_urlopen: MagicMock) -> None:
assert req.get_method() == "DELETE"
assert req.full_url == f"{BASE}/users/u1/follow"

@patch("colony_sdk.client.urlopen")
def test_get_followers(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.get_followers("u1")

req = _last_request(mock_urlopen)
assert req.get_method() == "GET"
assert req.full_url == f"{BASE}/users/u1/followers?limit=50&offset=0"

@patch("colony_sdk.client.urlopen")
def test_get_following_pagination(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.get_following("u1", limit=10, offset=20)

req = _last_request(mock_urlopen)
assert req.full_url == f"{BASE}/users/u1/following?limit=10&offset=20"

@patch("colony_sdk.client.urlopen")
def test_bookmark_post(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({})
client = _authed_client()

client.bookmark_post("p1")

req = _last_request(mock_urlopen)
assert req.get_method() == "POST"
assert req.full_url == f"{BASE}/posts/p1/bookmark"

@patch("colony_sdk.client.urlopen")
def test_unbookmark_post(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({})
client = _authed_client()

client.unbookmark_post("p1")

req = _last_request(mock_urlopen)
assert req.get_method() == "DELETE"
assert req.full_url == f"{BASE}/posts/p1/bookmark"

@patch("colony_sdk.client.urlopen")
def test_list_bookmarks(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.list_bookmarks()

req = _last_request(mock_urlopen)
assert req.full_url == f"{BASE}/posts/bookmarks/list?limit=20&offset=0"

@patch("colony_sdk.client.urlopen")
def test_watch_post(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({})
client = _authed_client()

client.watch_post("p1")

req = _last_request(mock_urlopen)
assert req.get_method() == "POST"
assert req.full_url == f"{BASE}/posts/p1/watch"

@patch("colony_sdk.client.urlopen")
def test_unwatch_post(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({})
client = _authed_client()

client.unwatch_post("p1")

req = _last_request(mock_urlopen)
assert req.get_method() == "DELETE"
assert req.full_url == f"{BASE}/posts/p1/watch"

@patch("colony_sdk.client.urlopen")
def test_conversation_history(self, mock_urlopen: MagicMock) -> None:
"""``before`` is a required anchor and lands in the query string."""
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.conversation_history("alice", before="m9", limit=100)

req = _last_request(mock_urlopen)
assert req.get_method() == "GET"
assert req.full_url == f"{BASE}/messages/conversations/alice/history?before=m9&limit=100"

@patch("colony_sdk.client.urlopen")
def test_conversation_tail_with_since_id(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.conversation_tail("alice", since_id="m42")

req = _last_request(mock_urlopen)
assert req.full_url == f"{BASE}/messages/conversations/alice/tail?limit=50&since_id=m42"

@patch("colony_sdk.client.urlopen")
def test_conversation_tail_omits_absent_since_id(self, mock_urlopen: MagicMock) -> None:
"""Without ``since_id`` the param is left out, not sent as null."""
mock_urlopen.return_value = _mock_response({"items": []})
client = _authed_client()

client.conversation_tail("alice")

req = _last_request(mock_urlopen)
assert req.full_url == f"{BASE}/messages/conversations/alice/tail?limit=50"


# ---------------------------------------------------------------------------
# Safety / Moderation
Expand Down
Loading