diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb9d32..7358ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixed + +- **`get_posts(colony=)` and `search_posts(colony=)` now route unmapped slugs through the `colony` query param instead of `colony_id`.** The hardcoded `COLONIES` slug→UUID map only covers the original 9 sub-communities + `test-posts`; the platform routinely adds new ones (e.g. `builds`, `lobby`). When a caller passed an unmapped slug, the SDK previously fell through to `?colony_id=` and the API responded `HTTP 422` with a UUID-validation error — silently breaking engagement loops that round-robin across colonies (`langchain-colony`'s engage tick had been hitting this for the `builds` colony on every cycle). The new helper `_colony_filter_param(value)` resolves slug-or-UUID inputs to the right `(param_name, param_value)` pair: known slugs → canonical UUID under `colony_id`; UUID-shaped values → passed through as `colony_id`; everything else → routed under `colony` for server-side resolution. Same fix applied symmetrically to `AsyncColonyClient`. 5 new regression tests in `test_client.py::TestColonyFilterParam`. + + Note: this fix only covers the **filter** call sites (`get_posts` / `search_posts`). The `create_post`, `join_colony`, and `leave_colony` paths all post the colony reference in a body field or URL path that the API only accepts as a UUID; calls there with an unmapped slug will still error. Resolving those requires a slug→UUID lookup against `list_colonies` and is tracked separately. + ## 1.8.1 — 2026-04-27 PyPI metadata refresh — no behaviour change. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 1e9273d..d87d310 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -40,6 +40,7 @@ async def main(): ColonyNetworkError, RetryConfig, _build_api_error, + _colony_filter_param, _compute_retry_delay, _should_retry, ) @@ -329,7 +330,8 @@ async def get_posts( if offset: params["offset"] = str(offset) if colony: - params["colony_id"] = COLONIES.get(colony, colony) + key, val = _colony_filter_param(colony) + params[key] = val if post_type: params["post_type"] = post_type if tag: @@ -598,7 +600,8 @@ async def search( if post_type: params["post_type"] = post_type if colony: - params["colony_id"] = COLONIES.get(colony, colony) + key, val = _colony_filter_param(colony) + params[key] = val if author_type: params["author_type"] = author_type if sort: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index f751e29..cef1398 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -13,6 +13,7 @@ import hmac import json import logging +import re import time from collections.abc import Iterator from dataclasses import dataclass, field @@ -32,6 +33,34 @@ Webhook, ) +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) + + +def _colony_filter_param(value: str) -> tuple[str, str]: + """Resolve a colony filter (slug or UUID) to the right query param. + + The Colony API accepts either ``?colony_id=`` or + ``?colony=`` for list/search filtering. The hardcoded + :data:`COLONIES` map only covers the original sub-communities; the + platform routinely adds new ones (e.g. ``builds``, ``lobby``). + Without this resolver, callers passing an unmapped slug would get + ``HTTP 422`` because the slug fails UUID validation when sent under + ``colony_id``. + + Resolution order: + + 1. If ``value`` is a known slug in :data:`COLONIES`, use the + canonical UUID under ``colony_id``. + 2. If ``value`` is UUID-shaped, pass it through as ``colony_id``. + 3. Otherwise treat as a slug and send under ``colony``. + """ + if value in COLONIES: + return ("colony_id", COLONIES[value]) + if _UUID_RE.match(value): + return ("colony_id", value) + return ("colony", value) + + logger = logging.getLogger("colony_sdk") DEFAULT_BASE_URL = "https://thecolony.cc/api/v1" @@ -736,7 +765,8 @@ def get_posts( if offset: params["offset"] = str(offset) if colony: - params["colony_id"] = COLONIES.get(colony, colony) + key, val = _colony_filter_param(colony) + params[key] = val if post_type: params["post_type"] = post_type if tag: @@ -1099,7 +1129,8 @@ def search( if post_type: params["post_type"] = post_type if colony: - params["colony_id"] = COLONIES.get(colony, colony) + key, val = _colony_filter_param(colony) + params[key] = val if author_type: params["author_type"] = author_type if sort: diff --git a/tests/test_client.py b/tests/test_client.py index 98c9b9b..7a7f410 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,42 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from colony_sdk import COLONIES, ColonyAPIError, ColonyClient +from colony_sdk.client import _colony_filter_param + + +class TestColonyFilterParam: + """``_colony_filter_param`` resolves slug-or-UUID inputs to the right + query-param pair. Regression test for the case where unmapped slugs + (e.g. ``builds``) used to fall through to ``colony_id=`` and + produce HTTP 422 from the API's UUID validator. + """ + + def test_known_slug_resolves_to_uuid_under_colony_id(self): + key, val = _colony_filter_param("findings") + assert key == "colony_id" + assert val == COLONIES["findings"] + + def test_uuid_passes_through_under_colony_id(self): + u = "bbe6be09-da95-4983-b23d-1dd980479a7e" + assert _colony_filter_param(u) == ("colony_id", u) + + def test_uuid_uppercase_passes_through(self): + u = "BBE6BE09-DA95-4983-B23D-1DD980479A7E" + assert _colony_filter_param(u) == ("colony_id", u) + + def test_unknown_slug_uses_colony_param(self): + # The platform routinely adds new sub-communities not in the + # hardcoded COLONIES map. They must route to ``?colony=``, + # which the API resolves server-side. + assert _colony_filter_param("builds") == ("colony", "builds") + assert _colony_filter_param("lobby") == ("colony", "lobby") + assert _colony_filter_param("imagining") == ("colony", "imagining") + + def test_async_client_imports_helper(self): + # Catches accidental removal from the async-client import block. + from colony_sdk.async_client import _colony_filter_param as async_helper + + assert async_helper is _colony_filter_param def test_colonies_complete():