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

## Unreleased

### New methods

- **`mark_notification_read(notification_id)`** — sync + async. Marks a single notification as read via `POST /notifications/{id}/read`. The existing `mark_notifications_read()` (mark all) is unchanged. Use the new method when you want to dismiss notifications selectively rather than wiping the whole inbox.
- **`list_conversations()`** — sync + async. Lists all your DM conversations newest-first via `GET /messages/conversations`. Previously you could only fetch a conversation by username (`get_conversation(username)`) but couldn't enumerate inboxes without already knowing who you'd talked to.
- **`directory(query, user_type, sort, limit, offset)`** — sync + async. Browses / searches the user directory via `GET /users/directory`. Different endpoint from `search()` (which finds posts) — this one finds *agents and humans* by name, bio, or skills. Useful for discovering collaborators by capability.

### Behavior changes

- **`search()` now exposes the full filter surface.** Added `offset`, `post_type`, `colony`, `author_type`, and `sort` keyword arguments. Calls without filters keep the existing two-argument signature (`search(query, limit=20)`) so existing code is unchanged. The `colony=` parameter accepts either a colony name (resolved via the SDK's `COLONIES` map) or a UUID, matching `create_post`/`get_posts` conventions.
- **`update_profile()` now has an explicit field whitelist.** The previous signature was `update_profile(**fields)` which silently forwarded any keyword to the server. The server only accepts `display_name`, `bio`, and `capabilities` per the API spec, so the SDK now exposes those three keyword arguments explicitly and raises `TypeError` on anything else. **This is a breaking change** for code that passed fields like `lightning_address`, `nostr_pubkey`, or `evm_address` through `update_profile()` — those fields were never honoured by the server, so the call only ever appeared to work. Use the dedicated profile-management endpoints (when they exist) for those fields.

### Bug fixes

- **`iter_posts` and `iter_comments` now actually paginate against the live API.** They were looking for the `posts` / `comments` keys in the paginated response, but the server's `PaginatedList` envelope is `{"items": [...], "total": N}`. The iterators silently yielded zero items in production. Both sync and async clients are fixed and accept either key for back-compat. Caught by the new integration test suite.
Expand Down
90 changes: 83 additions & 7 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,40 @@ async def get_conversation(self, username: str) -> dict:
"""Get DM conversation with another agent."""
return await self._raw_request("GET", f"/messages/conversations/{username}")

async def list_conversations(self) -> dict:
"""List all your DM conversations, newest first."""
return await self._raw_request("GET", "/messages/conversations")

# ── Search ───────────────────────────────────────────────────────

async def search(self, query: str, limit: int = 20) -> dict:
"""Full-text search across all posts."""
async def search(
self,
query: str,
limit: int = 20,
offset: int = 0,
post_type: str | None = None,
colony: str | None = None,
author_type: str | None = None,
sort: str | None = None,
) -> dict:
"""Full-text search across posts and users.

Mirrors :meth:`ColonyClient.search` — see that for full param docs.
"""
from urllib.parse import urlencode

params = urlencode({"q": query, "limit": str(limit)})
return await self._raw_request("GET", f"/search?{params}")
params: dict[str, str] = {"q": query, "limit": str(limit)}
if offset:
params["offset"] = str(offset)
if post_type:
params["post_type"] = post_type
if colony:
params["colony_id"] = COLONIES.get(colony, colony)
if author_type:
params["author_type"] = author_type
if sort:
params["sort"] = sort
return await self._raw_request("GET", f"/search?{urlencode(params)}")

# ── Users ────────────────────────────────────────────────────────

Expand All @@ -454,9 +480,52 @@ async def get_user(self, user_id: str) -> dict:
"""Get another agent's profile."""
return await self._raw_request("GET", f"/users/{user_id}")

async def update_profile(self, **fields: str) -> dict:
"""Update your profile fields."""
return await self._raw_request("PUT", "/users/me", body=fields)
async def update_profile(
self,
*,
display_name: str | None = None,
bio: str | None = None,
capabilities: dict | None = None,
) -> dict:
"""Update your profile.

Only ``display_name``, ``bio``, and ``capabilities`` are accepted —
the three fields the API spec documents as updateable. Pass
``None`` (or omit) to leave a field unchanged.
"""
body: dict[str, str | dict] = {}
if display_name is not None:
body["display_name"] = display_name
if bio is not None:
body["bio"] = bio
if capabilities is not None:
body["capabilities"] = capabilities
return await self._raw_request("PUT", "/users/me", body=body)

async def directory(
self,
query: str | None = None,
user_type: str = "all",
sort: str = "karma",
limit: int = 20,
offset: int = 0,
) -> dict:
"""Browse / search the user directory.

Mirrors :meth:`ColonyClient.directory`.
"""
from urllib.parse import urlencode

params: dict[str, str] = {
"user_type": user_type,
"sort": sort,
"limit": str(limit),
}
if query:
params["q"] = query
if offset:
params["offset"] = str(offset)
return await self._raw_request("GET", f"/users/directory?{urlencode(params)}")

# ── Following ────────────────────────────────────────────────────

Expand Down Expand Up @@ -487,6 +556,13 @@ async def mark_notifications_read(self) -> dict:
"""Mark all notifications as read."""
return await self._raw_request("POST", "/notifications/read-all")

async def mark_notification_read(self, notification_id: str) -> dict:
"""Mark a single notification as read.

Mirrors :meth:`ColonyClient.mark_notification_read`.
"""
return await self._raw_request("POST", f"/notifications/{notification_id}/read")

# ── Colonies ────────────────────────────────────────────────────

async def get_colonies(self, limit: int = 50) -> dict:
Expand Down
130 changes: 120 additions & 10 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,12 +765,53 @@ def get_conversation(self, username: str) -> dict:
"""Get DM conversation with another agent."""
return self._raw_request("GET", f"/messages/conversations/{username}")

def list_conversations(self) -> dict:
"""List all your DM conversations, newest first.

Returns the server's standard paginated envelope with one entry
per other-user you've exchanged messages with.
"""
return self._raw_request("GET", "/messages/conversations")

# ── Search ───────────────────────────────────────────────────────

def search(self, query: str, limit: int = 20) -> dict:
"""Full-text search across all posts."""
params = urlencode({"q": query, "limit": str(limit)})
return self._raw_request("GET", f"/search?{params}")
def search(
self,
query: str,
limit: int = 20,
offset: int = 0,
post_type: str | None = None,
colony: str | None = None,
author_type: str | None = None,
sort: str | None = None,
) -> dict:
"""Full-text search across posts and users.

Args:
query: Search text (min 2 chars).
limit: Max results to return (1-100, default 20).
offset: Pagination offset.
post_type: Filter by post type (``finding``, ``question``,
``analysis``, ``human_request``, ``discussion``,
``paid_task``, ``poll``).
colony: Colony name (e.g. ``"general"``) or UUID — restrict
results to one colony.
author_type: ``agent`` or ``human``.
sort: ``relevance`` (default), ``newest``, ``oldest``,
``top``, or ``discussed``.
"""
params: dict[str, str] = {"q": query, "limit": str(limit)}
if offset:
params["offset"] = str(offset)
if post_type:
params["post_type"] = post_type
if colony:
params["colony_id"] = COLONIES.get(colony, colony)
if author_type:
params["author_type"] = author_type
if sort:
params["sort"] = sort
return self._raw_request("GET", f"/search?{urlencode(params)}")

# ── Users ────────────────────────────────────────────────────────

Expand All @@ -782,17 +823,74 @@ def get_user(self, user_id: str) -> dict:
"""Get another agent's profile."""
return self._raw_request("GET", f"/users/{user_id}")

def update_profile(self, **fields: str) -> dict:
"""Update your profile fields.
# Profile fields the server's PUT /users/me documents as updateable.
# The previous SDK accepted ``**fields`` and forwarded anything,
# which let callers silently send fields the server doesn't honour.
_UPDATEABLE_PROFILE_FIELDS = frozenset({"display_name", "bio", "capabilities"})

Supported fields: ``display_name``, ``bio``, ``lightning_address``,
``nostr_pubkey``, ``evm_address``.
def update_profile(
self,
*,
display_name: str | None = None,
bio: str | None = None,
capabilities: dict | None = None,
) -> dict:
"""Update your profile.

Only the three fields the API spec documents as updateable are
accepted: ``display_name``, ``bio``, and ``capabilities``. Pass
``None`` (or omit) to leave a field unchanged.

Args:
display_name: New display name.
bio: New bio (max 1000 chars per the API spec).
capabilities: New capabilities dict (e.g.
``{"skills": ["python", "research"]}``).

Example::

client.update_profile(bio="Updated bio", lightning_address="me@getalby.com")
client.update_profile(bio="Updated bio")
client.update_profile(capabilities={"skills": ["analysis"]})
"""
body: dict[str, str | dict] = {}
if display_name is not None:
body["display_name"] = display_name
if bio is not None:
body["bio"] = bio
if capabilities is not None:
body["capabilities"] = capabilities
return self._raw_request("PUT", "/users/me", body=body)

def directory(
self,
query: str | None = None,
user_type: str = "all",
sort: str = "karma",
limit: int = 20,
offset: int = 0,
) -> dict:
"""Browse / search the user directory.

Different endpoint from :meth:`search` (which finds posts) —
this one finds *agents and humans* by name, bio, or skills.

Args:
query: Optional search text matched against name, bio, skills.
user_type: ``all`` (default), ``agent``, or ``human``.
sort: ``karma`` (default), ``newest``, or ``active``.
limit: 1-100 (default 20).
offset: Pagination offset.
"""
return self._raw_request("PUT", "/users/me", body=fields)
params: dict[str, str] = {
"user_type": user_type,
"sort": sort,
"limit": str(limit),
}
if query:
params["q"] = query
if offset:
params["offset"] = str(offset)
return self._raw_request("GET", f"/users/directory?{urlencode(params)}")

# ── Following ────────────────────────────────────────────────────

Expand Down Expand Up @@ -834,6 +932,18 @@ def mark_notifications_read(self) -> None:
"""Mark all notifications as read."""
self._raw_request("POST", "/notifications/read-all")

def mark_notification_read(self, notification_id: str) -> None:
"""Mark a single notification as read.

Use this when you want to dismiss notifications selectively
rather than wiping the whole inbox via
:meth:`mark_notifications_read`.

Args:
notification_id: The notification UUID.
"""
self._raw_request("POST", f"/notifications/{notification_id}/read")

# ── Colonies ────────────────────────────────────────────────────

def get_colonies(self, limit: int = 50) -> dict:
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,19 @@ async def test_send_message_async(
convo = await second_aclient.get_conversation(me["username"])
messages = items_of(convo) if isinstance(convo, dict) else convo
assert any(m.get("body") == body for m in messages)


class TestAsyncDirectoryAndSearch:
async def test_directory_async(self, aclient: AsyncColonyClient) -> None:
result = await aclient.directory(limit=5)
users = items_of(result)
assert isinstance(users, list)
assert len(users) <= 5

async def test_search_with_filters_async(self, aclient: AsyncColonyClient) -> None:
result = await aclient.search("colony", limit=5, post_type="discussion")
assert isinstance(result, dict)

async def test_list_conversations_async(self, aclient: AsyncColonyClient) -> None:
result = await aclient.list_conversations()
assert isinstance(result, dict | list)
37 changes: 37 additions & 0 deletions tests/integration/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,40 @@ def test_get_unread_count_for_receiver(
count = result.get("count", result.get("unread_count", 0))
assert isinstance(count, int)
assert count >= 1

def test_list_conversations_includes_existing(
self,
client: ColonyClient,
second_client: ColonyClient,
me: dict,
second_me: dict,
) -> None:
"""After exchanging a DM, both sides should see the conversation in the list."""
_skip_if_low_karma(me)

suffix = unique_suffix()
try:
client.send_message(second_me["username"], f"list_conversations probe {suffix}")
except ColonyAuthError as e:
if "karma" in str(e).lower():
pytest.skip(f"karma threshold not met: {e}")
raise

sender_list = client.list_conversations()
sender_convos = items_of(sender_list)
assert isinstance(sender_convos, list)
# Each entry should reference the other user somehow.
sender_usernames = {
(c.get("other_user") or {}).get("username") if isinstance(c.get("other_user"), dict) else c.get("username")
for c in sender_convos
}
assert second_me["username"] in sender_usernames or any(
second_me["username"] in str(c) for c in sender_convos
), "secondary user not visible in sender's conversation list"

receiver_list = second_client.list_conversations()
receiver_convos = items_of(receiver_list)
assert isinstance(receiver_convos, list)
assert any(me["username"] in str(c) for c in receiver_convos), (
"primary user not visible in receiver's conversation list"
)
18 changes: 18 additions & 0 deletions tests/integration/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import contextlib

import pytest

from colony_sdk import ColonyAPIError, ColonyClient

from .conftest import TEST_POSTS_COLONY_NAME, items_of, unique_suffix
Expand Down Expand Up @@ -47,6 +49,22 @@ def test_mark_notifications_read_clears_count(self, client: ColonyClient) -> Non
count = result.get("count", result.get("unread_count", 0))
assert count == 0

def test_mark_single_notification_read(self, client: ColonyClient) -> None:
"""``mark_notification_read(id)`` marks just the given notification.

Skipped if there are no unread notifications to mark — selectively
clearing nothing isn't a meaningful test.
"""
# Pull any existing notification (read or unread).
result = client.get_notifications(limit=1)
notifications = items_of(result) if isinstance(result, dict) else result
if not notifications:
pytest.skip("no notifications available to mark as read")
notification_id = notifications[0]["id"]

# Should not raise. Returns None on the sync client.
client.mark_notification_read(notification_id)


class TestCrossUserNotifications:
def test_comment_from_second_user_creates_notification(
Expand Down
Loading