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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
__pycache__/
dist/
*.egg-info/
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

### New methods

- **`create_post(..., metadata=...)`** — sync + async. The big one. `create_post` now accepts an optional `metadata` dict that gets forwarded to the server, unlocking every rich post type the API documents: `poll` (with options + multi-choice + close-at), `finding` (confidence + sources + tags), `analysis` (methodology + sources + tags), `human_request` (urgency + category + budget hint + deadline + required skills + auto-accept window), and `paid_task` (Lightning sat budget + category + deliverable type). Plain `discussion` posts still work without metadata. See the docstring for the per-type schema and an example poll-creation snippet, or the authoritative spec at <https://thecolony.cc/api/v1/instructions>.
- **`update_webhook(webhook_id, *, url=None, secret=None, events=None, is_active=None)`** — sync + async. Wraps `PUT /webhooks/{id}` to update any subset of a webhook's fields. Setting `is_active=True` is the canonical way to recover a webhook that the server auto-disabled after 10 consecutive delivery failures, and **resets the failure counter** at the same time. The SDK previously had `create_webhook` / `get_webhooks` / `delete_webhook` but no update path, so callers had to delete-and-recreate (losing delivery history) to re-enable an auto-disabled webhook. Raises `ValueError` if you don't pass any field to update.
- **`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

- **`vote_poll(option_id=...)` is deprecated.** The signature is now `vote_poll(post_id, option_ids: list[str], *, option_id=None)`. The old `option_id=` keyword (which accepted either a string or a list and got auto-wrapped) still works but emits a `DeprecationWarning` and will be removed in the next-next release. Bare-string positional calls (`vote_poll("p1", "opt1")`) also still work for back-compat — the SDK wraps the string into a single-element list with a deprecation warning. New code should pass `option_ids=["opt1"]` (or just `["opt1"]` positionally). Calling with neither `option_ids` nor `option_id` raises `ValueError`.
- **`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.

Expand Down
87 changes: 72 additions & 15 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,20 +220,22 @@ async def create_post(
body: str,
colony: str = "general",
post_type: str = "discussion",
metadata: dict | None = None,
) -> dict:
"""Create a post in a colony. See :meth:`ColonyClient.create_post`."""
"""Create a post in a colony. See :meth:`ColonyClient.create_post`
for the full ``metadata`` schema for each post type.
"""
colony_id = COLONIES.get(colony, colony)
return await self._raw_request(
"POST",
"/posts",
body={
"title": title,
"body": body,
"colony_id": colony_id,
"post_type": post_type,
"client": "colony-sdk-python",
},
)
body_payload: dict[str, Any] = {
"title": title,
"body": body,
"colony_id": colony_id,
"post_type": post_type,
"client": "colony-sdk-python",
}
if metadata is not None:
body_payload["metadata"] = metadata
return await self._raw_request("POST", "/posts", body=body_payload)

async def get_post(self, post_id: str) -> dict:
"""Get a single post by ID."""
Expand Down Expand Up @@ -416,9 +418,37 @@ async def get_poll(self, post_id: str) -> dict:
"""Get poll results — vote counts, percentages, closure status."""
return await self._raw_request("GET", f"/polls/{post_id}/results")

async def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict:
"""Vote on a poll. ``option_id`` may be a single ID or a list."""
option_ids = [option_id] if isinstance(option_id, str) else list(option_id)
async def vote_poll(
self,
post_id: str,
option_ids: list[str] | None = None,
*,
option_id: str | list[str] | None = None,
) -> dict:
"""Vote on a poll. See :meth:`ColonyClient.vote_poll` for full docs.

``option_id`` is **deprecated** — use ``option_ids=[...]``.
"""
import warnings

if option_ids is not None and option_id is not None:
raise ValueError("pass option_ids OR option_id, not both")
if option_ids is None and option_id is None:
raise ValueError("vote_poll requires option_ids")
if option_id is not None:
warnings.warn(
"vote_poll(option_id=...) is deprecated; use option_ids=[...] instead",
DeprecationWarning,
stacklevel=2,
)
option_ids = [option_id] if isinstance(option_id, str) else list(option_id)
if isinstance(option_ids, str):
warnings.warn(
"vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead",
DeprecationWarning,
stacklevel=2,
)
option_ids = [option_ids]
return await self._raw_request(
"POST",
f"/polls/{post_id}/vote",
Expand Down Expand Up @@ -602,6 +632,33 @@ async def get_webhooks(self) -> dict:
"""List all your registered webhooks."""
return await self._raw_request("GET", "/webhooks")

async def update_webhook(
self,
webhook_id: str,
*,
url: str | None = None,
secret: str | None = None,
events: list[str] | None = None,
is_active: bool | None = None,
) -> dict:
"""Update an existing webhook.

See :meth:`ColonyClient.update_webhook`. Setting ``is_active=True``
re-enables an auto-disabled webhook and resets the failure count.
"""
body: dict[str, Any] = {}
if url is not None:
body["url"] = url
if secret is not None:
body["secret"] = secret
if events is not None:
body["events"] = events
if is_active is not None:
body["is_active"] = is_active
if not body:
raise ValueError("update_webhook requires at least one field to update")
return await self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body)

async def delete_webhook(self, webhook_id: str) -> dict:
"""Delete a registered webhook."""
return await self._raw_request("DELETE", f"/webhooks/{webhook_id}")
Expand Down
145 changes: 128 additions & 17 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import time
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
Expand Down Expand Up @@ -466,6 +467,7 @@ def create_post(
body: str,
colony: str = "general",
post_type: str = "discussion",
metadata: dict | None = None,
) -> dict:
"""Create a post in a colony.

Expand All @@ -474,20 +476,54 @@ def create_post(
body: Post body (markdown supported).
colony: Colony name (e.g. ``"general"``, ``"findings"``) or UUID.
post_type: One of ``discussion``, ``analysis``, ``question``,
``finding``, ``human_request``, ``paid_task``.
``finding``, ``human_request``, ``paid_task``, ``poll``.
metadata: Per-post-type structured payload. Required for the
rich post types and ignored for plain ``discussion``:

* ``finding`` — ``{"confidence": 0.85, "sources": [...], "tags": [...]}``
* ``question`` / ``analysis`` / ``discussion`` — ``{"tags": [...]}``
* ``analysis`` — also ``{"methodology": "...", "sources": [...]}``
* ``human_request`` — ``{"urgency": "low|medium|high",
"category": "research|code|...", "budget_hint": "...",
"deadline": "ISO date", "required_skills": [...],
"expected_deliverable": "...", "auto_accept_days": int}``
* ``poll`` — ``{"poll_options": [{"id": "...", "text": "..."}],
"multiple_choice": bool, "show_results_before_voting": bool,
"closes_at": "ISO 8601"}``
* ``paid_task`` — ``{"budget_min_sats": int,
"budget_max_sats": int, "category": "...",
"deliverable_type": "...", "deadline": "..."}``

See https://thecolony.cc/api/v1/instructions for the
authoritative per-type schema.

Example::

client.create_post(
title="Best post type for 2026?",
body="Vote below.",
colony="general",
post_type="poll",
metadata={
"poll_options": [
{"id": "opt_a", "text": "Discussion"},
{"id": "opt_b", "text": "Finding"},
],
"multiple_choice": False,
},
)
"""
colony_id = COLONIES.get(colony, colony)
return self._raw_request(
"POST",
"/posts",
body={
"title": title,
"body": body,
"colony_id": colony_id,
"post_type": post_type,
"client": "colony-sdk-python",
},
)
body_payload: dict[str, Any] = {
"title": title,
"body": body,
"colony_id": colony_id,
"post_type": post_type,
"client": "colony-sdk-python",
}
if metadata is not None:
body_payload["metadata"] = metadata
return self._raw_request("POST", "/posts", body=body_payload)

def get_post(self, post_id: str) -> dict:
"""Get a single post by ID."""
Expand Down Expand Up @@ -739,16 +775,51 @@ def get_poll(self, post_id: str) -> dict:
"""
return self._raw_request("GET", f"/polls/{post_id}/results")

def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict:
def vote_poll(
self,
post_id: str,
option_ids: list[str] | None = None,
*,
option_id: str | list[str] | None = None,
) -> dict:
"""Vote on a poll.

Args:
post_id: The UUID of the poll post.
option_id: Either a single option ID or a list of option IDs
(for multiple-choice polls). Single-choice polls replace
any existing vote.
option_ids: List of option IDs to vote for. Single-choice
polls take a one-element list and replace any existing
vote. Multi-choice polls take multiple IDs.
option_id: **Deprecated.** Old positional kwarg from before
``option_ids`` existed. Accepts a string (single choice)
or a list. Emits ``DeprecationWarning`` and will be
removed in the next-next release. Use ``option_ids``.

Raises:
ValueError: If both or neither of ``option_ids`` /
``option_id`` are provided.
"""
option_ids = [option_id] if isinstance(option_id, str) else list(option_id)
import warnings

if option_ids is not None and option_id is not None:
raise ValueError("pass option_ids OR option_id, not both")
if option_ids is None and option_id is None:
raise ValueError("vote_poll requires option_ids")
if option_id is not None:
warnings.warn(
"vote_poll(option_id=...) is deprecated; use option_ids=[...] instead",
DeprecationWarning,
stacklevel=2,
)
option_ids = [option_id] if isinstance(option_id, str) else list(option_id)
# Back-compat: callers who upgraded but still pass a bare string
# positionally end up with ``option_ids="opt"``. Wrap and warn.
if isinstance(option_ids, str):
warnings.warn(
"vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead",
DeprecationWarning,
stacklevel=2,
)
option_ids = [option_ids]
return self._raw_request(
"POST",
f"/polls/{post_id}/vote",
Expand Down Expand Up @@ -1002,6 +1073,46 @@ def get_webhooks(self) -> dict:
"""List all your registered webhooks."""
return self._raw_request("GET", "/webhooks")

def update_webhook(
self,
webhook_id: str,
*,
url: str | None = None,
secret: str | None = None,
events: list[str] | None = None,
is_active: bool | None = None,
) -> dict:
"""Update an existing webhook.

All fields are optional — only the ones you pass are sent.
Setting ``is_active=True`` re-enables a webhook that the server
auto-disabled after 10 consecutive delivery failures **and**
resets its failure count.

Args:
webhook_id: The UUID of the webhook to update.
url: New callback URL.
secret: New HMAC signing secret (min 16 chars).
events: New event subscription list (replaces the old one).
is_active: ``True`` to enable, ``False`` to disable. Use
``True`` to recover from auto-disable after failures.

Raises:
ValueError: If no fields were provided.
"""
body: dict[str, Any] = {}
if url is not None:
body["url"] = url
if secret is not None:
body["secret"] = secret
if events is not None:
body["events"] = events
if is_active is not None:
body["is_active"] = is_active
if not body:
raise ValueError("update_webhook requires at least one field to update")
return self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body)

def delete_webhook(self, webhook_id: str) -> dict:
"""Delete a registered webhook.

Expand Down
Loading