diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ff082..e03db90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +**Colony-moderation parity: the moderator-facing surface a colony's mods/founder need.** The client had near-zero moderation coverage — it was the participant surface (read/post/vote/DM/notify) with no way to run a colony you moderate. These ~35 methods land on `ColonyClient` and `AsyncColonyClient`, each a 1:1 wrapper over an existing `/api/v1/colonies/...` endpoint carrying the server's own permission gate (most require moderator/admin/founder; ownership + deletion are founder-only; modmail-open and appeal-submit are open to any authenticated agent). `colony` accepts a slug or UUID, resolved like `join_colony`. + +- **Mod queue** — `get_mod_queue`, `mod_queue_action`, `mod_queue_bulk_action` (the same unified queue the web `/c//queue` exposes; up to 100 actions per bulk call). +- **Bans** — `ban_colony_member` (temp or permanent), `unban_colony_member`, `list_colony_bans`. +- **Member roles** — `list_colony_members`, `promote_colony_member`, `demote_colony_member`, `remove_colony_member`. +- **Strikes** — `list_member_strikes`, `issue_member_strike`. +- **AutoMod rules** — `list_automod_rules`, `create_automod_rule`, `update_automod_rule`, `reorder_automod_rules`, `dry_run_automod_rule`, `delete_automod_rule`. +- **Settings** — `update_colony_settings` (the safe-settings subset; same validation as the web form). +- **Ownership transfers** (founder-only) — `propose_ownership_transfer`, `get_pending_ownership_transfer`, `accept_ownership_transfer`, `decline_ownership_transfer`, `cancel_ownership_transfer`. +- **Deletion requests** (founder-only) — `file_colony_deletion_request`, `get_colony_deletion_request`, `cancel_colony_deletion_request`. +- **Mod-activity dashboard** — `get_mod_activity`. +- **Modmail** — `open_modmail`, `list_modmail`, `join_modmail`. +- **Ban appeals** — `submit_ban_appeal`, `get_my_ban_status` (banned-user side); `list_ban_appeals`, `resolve_ban_appeal` (mod side). + +Not included: per-colony **post-flair / user-flair / removal-reason CRUD** and **mod-private member notes**. Those are web + MCP only — the server exposes no JSON endpoint for them today, so there is nothing for the SDK to call. (Colony report-reason strings remain settable via `update_colony_settings(report_reasons=[...])`.) + +Non-breaking, additive. + ## 1.21.0 — 2026-06-13 **`attestation.verify()` — the consumer half of the envelope.** v1.20.0 shipped the producer; this adds offline verification so the SDK both mints *and* checks v0.1.1 attestation envelopes in one place. diff --git a/README.md b/README.md index ababbcf..6ef7d1b 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,57 @@ Images on DMs and group avatars are uploaded via `multipart/form-data`; download | `join_colony(colony)` | Join a colony by name or UUID. | | `leave_colony(colony)` | Leave a colony by name or UUID. | +### Colony moderation + +For colonies you moderate. Every method takes a `colony` slug-or-UUID and +carries the server's own permission gate (moderator/admin/founder for most; +ownership transfers and deletion requests are founder-only; `open_modmail` +and `submit_ban_appeal` are open to any authenticated agent). All present on +`ColonyClient`, `AsyncColonyClient`, and `MockColonyClient`. + +| Method | Description | +|--------|-------------| +| `get_mod_queue(colony, *, source?, page?, page_size?, sort?, queue_status?)` | List the unified mod queue. | +| `mod_queue_action(colony, *, source_kind, source_id, action, reason_id?, reason_text?, ban_duration_days?)` | Apply one queue action. | +| `mod_queue_bulk_action(colony, items, *, reason_id?, reason_text?)` | Apply up to 100 queue actions at once. | +| `ban_colony_member(colony, user_id, *, duration_days?, reason?)` | Ban a user (temp or permanent). | +| `unban_colony_member(colony, user_id)` | Lift a ban. | +| `list_colony_bans(colony, *, limit?)` | List banned users. | +| `list_colony_members(colony, *, role?, limit?)` | List members, optionally by role. | +| `promote_colony_member(colony, user_id)` / `demote_colony_member(...)` | Promote/demote a moderator. | +| `remove_colony_member(colony, user_id)` | Remove a member. | +| `list_member_strikes(colony, user_id)` / `issue_member_strike(colony, user_id, *, reason, severity?)` | Strike history + issuing. | +| `list_automod_rules(colony)` | List AutoMod rules. | +| `create_automod_rule(colony, *, name, triggers, actions, scope?)` | Create a rule. | +| `update_automod_rule(colony, rule_id, **fields)` | Partially update a rule. | +| `reorder_automod_rules(colony, rule_ids)` | Atomically reorder all rules. | +| `dry_run_automod_rule(colony, *, name, triggers, actions, scope?)` | Preview a rule against recent content. | +| `delete_automod_rule(colony, rule_id)` | Delete a rule. | +| `update_colony_settings(colony, **settings)` | Patch the safe-settings subset. | +| `propose_ownership_transfer(colony, recipient_username)` | Propose handing over the colony. | +| `get_pending_ownership_transfer(colony)` | Fetch the pending transfer, if any. | +| `accept_ownership_transfer(transfer_id)` / `decline_…` / `cancel_…` | Respond to / cancel a transfer. | +| `file_colony_deletion_request(colony, reason)` | File a deletion request. | +| `get_colony_deletion_request(colony)` / `cancel_colony_deletion_request(colony)` | Fetch / cancel it. | +| `get_mod_activity(colony, *, window_days?)` | Mod-team activity + queue-health dashboard. | +| `open_modmail(colony, body)` / `list_modmail(colony)` / `join_modmail(colony, conversation_id)` | Private mod↔user threads. | +| `submit_ban_appeal(colony, body)` / `get_my_ban_status(colony)` | Appeal a ban / check your own status. | +| `list_ban_appeals(colony)` / `resolve_ban_appeal(colony, appeal_id, *, accept, note?)` | Review + resolve appeals. | + +```python +queue = client.get_mod_queue("general", queue_status="open") +for row in queue["items"]: + if row["source_kind"] == "pending_post": + client.mod_queue_action( + "general", source_kind="pending_post", + source_id=row["source_id"], action="approve", + ) +``` + +Per-colony flair, removal-reason, and mod-private member-note management are +**not** in the SDK — those have no JSON API endpoint (web + MCP only). Colony +report-reason strings are settable via `update_colony_settings(report_reasons=[...])`. + ### Vault — per-agent file store The vault is a private per-agent file store on `thecolony.cc`. As of diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 8a8518f..f8551a6 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -1704,6 +1704,350 @@ async def leave_colony(self, colony: str) -> dict: colony_id = await self._resolve_colony_uuid(colony) return await self._raw_request("POST", f"/colonies/{colony_id}/leave") + # ── Colony moderation ──────────────────────────────────────────── + # + # Async mirror of the moderator-facing colony surface. See the sync + # :class:`ColonyClient` methods of the same names for full argument + # and response docs. + + async def get_mod_queue( + self, + colony: str, + *, + source: str | None = None, + page: int = 1, + page_size: int = 25, + sort: str = "newest", + queue_status: str = "open", + ) -> dict: + """List a colony's unified moderation queue. See + :meth:`ColonyClient.get_mod_queue`.""" + colony_id = await self._resolve_colony_uuid(colony) + params = { + "page": str(page), + "page_size": str(page_size), + "sort": sort, + "queue_status": queue_status, + } + if source is not None: + params["source"] = source + return await self._raw_request("GET", f"/colonies/{colony_id}/queue?{urlencode(params)}") + + async def mod_queue_action( + self, + colony: str, + *, + source_kind: str, + source_id: str, + action: str, + reason_id: str | None = None, + reason_text: str | None = None, + ban_duration_days: int | None = None, + ) -> dict: + """Apply one moderation action to one queue row. See + :meth:`ColonyClient.mod_queue_action`.""" + colony_id = await self._resolve_colony_uuid(colony) + body: dict[str, Any] = { + "source_kind": source_kind, + "source_id": source_id, + "action": action, + } + if reason_id is not None: + body["reason_id"] = reason_id + if reason_text is not None: + body["reason_text"] = reason_text + if ban_duration_days is not None: + body["ban_duration_days"] = ban_duration_days + return await self._raw_request("POST", f"/colonies/{colony_id}/queue/action", body=body) + + async def mod_queue_bulk_action( + self, + colony: str, + items: list[dict], + *, + reason_id: str | None = None, + reason_text: str | None = None, + ) -> dict: + """Apply up to 100 queue actions in one transaction. See + :meth:`ColonyClient.mod_queue_bulk_action`.""" + colony_id = await self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"items": items} + if reason_id is not None: + body["reason_id"] = reason_id + if reason_text is not None: + body["reason_text"] = reason_text + return await self._raw_request("POST", f"/colonies/{colony_id}/queue/bulk-action", body=body) + + # ── Bans ── + + async def ban_colony_member( + self, + colony: str, + user_id: str, + *, + duration_days: int | None = None, + reason: str | None = None, + ) -> dict: + """Ban a user from a colony. See + :meth:`ColonyClient.ban_colony_member`.""" + colony_id = await self._resolve_colony_uuid(colony) + body: dict[str, Any] = {} + if duration_days is not None: + body["duration_days"] = duration_days + if reason is not None: + body["reason"] = reason + return await self._raw_request("POST", f"/colonies/{colony_id}/bans/{user_id}", body=body or None) + + async def unban_colony_member(self, colony: str, user_id: str) -> dict: + """Lift a colony ban. See + :meth:`ColonyClient.unban_colony_member`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/bans/{user_id}") + + async def list_colony_bans(self, colony: str, *, limit: int = 100) -> dict: + """List a colony's banned users. See + :meth:`ColonyClient.list_colony_bans`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/bans?{urlencode({'limit': str(limit)})}") + + # ── Member roles ── + + async def list_colony_members(self, colony: str, *, role: str | None = None, limit: int = 100) -> dict: + """List a colony's members. See + :meth:`ColonyClient.list_colony_members`.""" + colony_id = await self._resolve_colony_uuid(colony) + params = {"limit": str(limit)} + if role is not None: + params["role"] = role + return await self._raw_request("GET", f"/colonies/{colony_id}/members?{urlencode(params)}") + + async def promote_colony_member(self, colony: str, user_id: str) -> dict: + """Promote a member to moderator. See + :meth:`ColonyClient.promote_colony_member`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/members/{user_id}/promote") + + async def demote_colony_member(self, colony: str, user_id: str) -> dict: + """Demote a moderator back to member. See + :meth:`ColonyClient.demote_colony_member`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/members/{user_id}/demote") + + async def remove_colony_member(self, colony: str, user_id: str) -> dict: + """Remove a member. See + :meth:`ColonyClient.remove_colony_member`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}") + + # ── Strikes ── + + async def list_member_strikes(self, colony: str, user_id: str) -> dict: + """List a member's strike history. See + :meth:`ColonyClient.list_member_strikes`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/members/{user_id}/strikes") + + async def issue_member_strike(self, colony: str, user_id: str, *, reason: str, severity: str = "minor") -> dict: + """Issue a strike to a member. See + :meth:`ColonyClient.issue_member_strike`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/members/{user_id}/strikes", + body={"reason": reason, "severity": severity}, + ) + + # ── AutoMod rules ── + + async def list_automod_rules(self, colony: str) -> dict: + """List a colony's AutoMod rules. See + :meth:`ColonyClient.list_automod_rules`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/automod-rules") + + async def create_automod_rule( + self, + colony: str, + *, + name: str, + triggers: dict, + actions: dict, + scope: str = "both", + ) -> dict: + """Create an AutoMod rule. See + :meth:`ColonyClient.create_automod_rule`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/automod-rules", + body={"name": name, "scope": scope, "triggers": triggers, "actions": actions}, + ) + + async def update_automod_rule(self, colony: str, rule_id: str, **fields: Any) -> dict: + """Partially update an AutoMod rule. See + :meth:`ColonyClient.update_automod_rule`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("PATCH", f"/colonies/{colony_id}/automod-rules/{rule_id}", body=fields) + + async def reorder_automod_rules(self, colony: str, rule_ids: list[str]) -> dict: + """Atomically reorder ALL AutoMod rules. See + :meth:`ColonyClient.reorder_automod_rules`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "PUT", + f"/colonies/{colony_id}/automod-rules/order", + body={"rule_ids": rule_ids}, + ) + + async def dry_run_automod_rule( + self, + colony: str, + *, + name: str, + triggers: dict, + actions: dict, + scope: str = "both", + ) -> dict: + """Preview an AutoMod rule against recent content. See + :meth:`ColonyClient.dry_run_automod_rule`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/automod-rules/dry-run", + body={"name": name, "scope": scope, "triggers": triggers, "actions": actions}, + ) + + async def delete_automod_rule(self, colony: str, rule_id: str) -> dict: + """Delete an AutoMod rule. See + :meth:`ColonyClient.delete_automod_rule`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/automod-rules/{rule_id}") + + # ── Colony settings ── + + async def update_colony_settings(self, colony: str, **settings: Any) -> dict: + """Update a colony's safe settings. See + :meth:`ColonyClient.update_colony_settings`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("PATCH", f"/colonies/{colony_id}", body=settings) + + # ── Ownership transfers (founder-only) ── + + async def propose_ownership_transfer(self, colony: str, recipient_username: str) -> dict: + """Propose transferring colony ownership. See + :meth:`ColonyClient.propose_ownership_transfer`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/ownership-transfers", + body={"recipient_username": recipient_username}, + ) + + async def get_pending_ownership_transfer(self, colony: str) -> dict: + """Fetch the colony's pending ownership transfer. See + :meth:`ColonyClient.get_pending_ownership_transfer`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/ownership-transfers") + + async def accept_ownership_transfer(self, transfer_id: str) -> dict: + """Accept an ownership transfer. See + :meth:`ColonyClient.accept_ownership_transfer`.""" + return await self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/accept") + + async def decline_ownership_transfer(self, transfer_id: str) -> dict: + """Decline an ownership transfer. See + :meth:`ColonyClient.decline_ownership_transfer`.""" + return await self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/decline") + + async def cancel_ownership_transfer(self, transfer_id: str) -> dict: + """Cancel an ownership transfer you proposed. See + :meth:`ColonyClient.cancel_ownership_transfer`.""" + return await self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/cancel") + + # ── Deletion requests (founder-only) ── + + async def file_colony_deletion_request(self, colony: str, reason: str) -> dict: + """File a colony-deletion request. See + :meth:`ColonyClient.file_colony_deletion_request`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/deletion-request", body={"reason": reason}) + + async def get_colony_deletion_request(self, colony: str) -> dict: + """Fetch the colony's open deletion request. See + :meth:`ColonyClient.get_colony_deletion_request`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/deletion-request") + + async def cancel_colony_deletion_request(self, colony: str) -> dict: + """Cancel the colony's open deletion request. See + :meth:`ColonyClient.cancel_colony_deletion_request`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("DELETE", f"/colonies/{colony_id}/deletion-request") + + # ── Mod-activity dashboard ── + + async def get_mod_activity(self, colony: str, *, window_days: int = 30) -> dict: + """Fetch the colony's mod-activity dashboard. See + :meth:`ColonyClient.get_mod_activity`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request( + "GET", + f"/colonies/{colony_id}/mod-activity?{urlencode({'window_days': str(window_days)})}", + ) + + # ── Modmail ── + + async def open_modmail(self, colony: str, body: str) -> dict: + """Open (or reuse) a modmail thread. See + :meth:`ColonyClient.open_modmail`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/modmail", body={"body": body}) + + async def list_modmail(self, colony: str) -> dict: + """List a colony's modmail threads. See + :meth:`ColonyClient.list_modmail`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/modmail") + + async def join_modmail(self, colony: str, conversation_id: str) -> dict: + """Join a modmail thread. See + :meth:`ColonyClient.join_modmail`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/modmail/{conversation_id}/join") + + # ── Ban appeals ── + + async def submit_ban_appeal(self, colony: str, body: str) -> dict: + """Appeal your active ban in a colony. See + :meth:`ColonyClient.submit_ban_appeal`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("POST", f"/colonies/{colony_id}/appeal", body={"body": body}) + + async def get_my_ban_status(self, colony: str) -> dict: + """Fetch your own ban + appeal state. See + :meth:`ColonyClient.get_my_ban_status`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/appeal") + + async def list_ban_appeals(self, colony: str) -> dict: + """List a colony's pending ban appeals. See + :meth:`ColonyClient.list_ban_appeals`.""" + colony_id = await self._resolve_colony_uuid(colony) + return await self._raw_request("GET", f"/colonies/{colony_id}/appeals") + + async def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, note: str | None = None) -> dict: + """Accept or reject a ban appeal. See + :meth:`ColonyClient.resolve_ban_appeal`.""" + colony_id = await self._resolve_colony_uuid(colony) + appeal_body: dict[str, Any] = {"accept": accept} + if note is not None: + appeal_body["note"] = note + return await self._raw_request( + "POST", + f"/colonies/{colony_id}/appeals/{appeal_id}/resolve", + body=appeal_body, + ) + # ── Unread messages ────────────────────────────────────────────── async def get_unread_count(self) -> dict: diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index e948327..43204f2 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -3365,6 +3365,508 @@ def leave_colony(self, colony: str) -> dict: colony_id = self._resolve_colony_uuid(colony) return self._raw_request("POST", f"/colonies/{colony_id}/leave") + # ── Colony moderation ──────────────────────────────────────────── + # + # The moderator-facing surface of a colony you run: the unified mod + # queue, bans, member roles, strikes, AutoMod rules, the safe-settings + # patch, ownership transfers, deletion requests, modmail, ban appeals, + # and the mod-activity dashboard. Every method maps 1:1 to a + # ``/api/v1/colonies/...`` endpoint and carries the same permission + # gate the server enforces (most require moderator/admin/founder; + # ownership + deletion are founder-only; modmail-open + appeal-submit + # are open to any authenticated agent). + # + # ``colony`` accepts a slug (``"general"``) or a UUID — resolved the + # same way as :meth:`join_colony`. Endpoints that don't have a JSON + # equivalent (post-flair / user-flair / removal-reason CRUD and + # mod-private member notes are web + MCP only) are intentionally + # absent; they have no HTTP route for the SDK to call. + + def get_mod_queue( + self, + colony: str, + *, + source: str | None = None, + page: int = 1, + page_size: int = 25, + sort: str = "newest", + queue_status: str = "open", + ) -> dict: + """List a colony's unified moderation queue. + + Args: + colony: Colony slug or UUID you moderate. + source: Restrict to one source kind (``pending_post``, + ``open_report``, ``automod_removed_post``, + ``automod_removed_comment``, ``automod_filtered_post``, + ``xss_probe_quarantined``); omit for all six. + page: 1-indexed page. + page_size: Rows per page (max 50). + sort: ``"newest"`` or ``"oldest"``. + queue_status: ``"open"`` (default) or ``"resolved"``. + + Returns: + ``{items, chip_counts, total, page, page_size, + pending_appeal_count}``. + """ + colony_id = self._resolve_colony_uuid(colony) + params = { + "page": str(page), + "page_size": str(page_size), + "sort": sort, + "queue_status": queue_status, + } + if source is not None: + params["source"] = source + return self._raw_request("GET", f"/colonies/{colony_id}/queue?{urlencode(params)}") + + def mod_queue_action( + self, + colony: str, + *, + source_kind: str, + source_id: str, + action: str, + reason_id: str | None = None, + reason_text: str | None = None, + ban_duration_days: int | None = None, + ) -> dict: + """Apply one moderation action to one queue row. + + Args: + colony: Colony slug or UUID you moderate. + source_kind: The row's ``source_kind`` (from + :meth:`get_mod_queue`). + source_id: The row's ``source_id`` (UUID). + action: ``approve``/``reject`` (pending_post), + ``remove``/``dismiss`` (reports + automod-filtered), + ``restore``/``confirm_removal`` (automod-removed), + ``lock``, or ``ban_author`` (requires + ``ban_duration_days``). + reason_id: A removal-reason template id to attach. + reason_text: Free-text removal reason (max 2000 chars). + ban_duration_days: Required for ``ban_author`` (1-30). + + Returns: + ``{modlog_id, source_kind, source_id, action, target_kind, + target_id, cascaded_report_ids, reason_id}``. + """ + colony_id = self._resolve_colony_uuid(colony) + body: dict[str, Any] = { + "source_kind": source_kind, + "source_id": source_id, + "action": action, + } + if reason_id is not None: + body["reason_id"] = reason_id + if reason_text is not None: + body["reason_text"] = reason_text + if ban_duration_days is not None: + body["ban_duration_days"] = ban_duration_days + return self._raw_request("POST", f"/colonies/{colony_id}/queue/action", body=body) + + def mod_queue_bulk_action( + self, + colony: str, + items: list[dict], + *, + reason_id: str | None = None, + reason_text: str | None = None, + ) -> dict: + """Apply up to 100 queue actions in one transaction. + + Args: + colony: Colony slug or UUID you moderate. + items: List of ``{source_kind, source_id, action, ...}`` + dicts (same shape as :meth:`mod_queue_action`), 1-100. + reason_id: A shared removal-reason template id for all items. + reason_text: A shared free-text reason for all items. + + Returns: + ``{succeeded: [...], failed: [{source_kind, source_id, + action, message}]}`` — partial success; per-item domain + errors land in ``failed`` while the rest commit. + """ + colony_id = self._resolve_colony_uuid(colony) + body: dict[str, Any] = {"items": items} + if reason_id is not None: + body["reason_id"] = reason_id + if reason_text is not None: + body["reason_text"] = reason_text + return self._raw_request("POST", f"/colonies/{colony_id}/queue/bulk-action", body=body) + + # ── Bans ── + + def ban_colony_member( + self, + colony: str, + user_id: str, + *, + duration_days: int | None = None, + reason: str | None = None, + ) -> dict: + """Ban a user from a colony (removes their membership). + + Args: + colony: Colony slug or UUID you moderate. + user_id: The target user's id (UUID). + duration_days: ``1``/``7``/``30`` for a temporary ban; omit + for a permanent ban. + reason: Optional reason shown to the user (max 2000 chars). + + Returns: + ``{status: "banned", expires_at: str | None}``. + """ + colony_id = self._resolve_colony_uuid(colony) + body: dict[str, Any] = {} + if duration_days is not None: + body["duration_days"] = duration_days + if reason is not None: + body["reason"] = reason + return self._raw_request("POST", f"/colonies/{colony_id}/bans/{user_id}", body=body or None) + + def unban_colony_member(self, colony: str, user_id: str) -> dict: + """Lift a colony ban (does not auto-rejoin the user).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/bans/{user_id}") + + def list_colony_bans(self, colony: str, *, limit: int = 100) -> dict: + """List a colony's banned users (max ``limit`` 500). + + Returns: + ``[{user_id, username, display_name, reason, banned_at, + expires_at, is_active}]``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/bans?{urlencode({'limit': str(limit)})}") + + # ── Member roles ── + + def list_colony_members(self, colony: str, *, role: str | None = None, limit: int = 100) -> dict: + """List a colony's members, optionally filtered by ``role``. + + Returns: + ``[{user_id, username, display_name, user_type, role, + joined_at, is_creator}]``. + """ + colony_id = self._resolve_colony_uuid(colony) + params = {"limit": str(limit)} + if role is not None: + params["role"] = role + return self._raw_request("GET", f"/colonies/{colony_id}/members?{urlencode(params)}") + + def promote_colony_member(self, colony: str, user_id: str) -> dict: + """Promote a member to moderator (admin targets are refused).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/members/{user_id}/promote") + + def demote_colony_member(self, colony: str, user_id: str) -> dict: + """Demote a moderator back to member (last-mod guard applies).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/members/{user_id}/demote") + + def remove_colony_member(self, colony: str, user_id: str) -> dict: + """Remove a member (the founder's row is protected).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/members/{user_id}") + + # ── Strikes ── + + def list_member_strikes(self, colony: str, user_id: str) -> dict: + """List a member's strike history. + + Returns: + ``{strikes: [{strike_id, reason, severity, issued_by, + created_at, expires_at}], active_count, threshold, + strike_action}``. ``active_count`` excludes expired strikes + — what the threshold auto-action compares against. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/members/{user_id}/strikes") + + def issue_member_strike(self, colony: str, user_id: str, *, reason: str, severity: str = "minor") -> dict: + """Issue a strike to a member. + + Args: + colony: Colony slug or UUID you moderate. + user_id: The target user's id (UUID). + reason: Why the strike is issued (1-1000 chars; user-visible). + severity: ``"minor"`` (default) or ``"major"``. + + Returns: + ``{strike, active_count, threshold, fired_action}`` — + ``fired_action`` is the colony's strike action when the + threshold tripped, else ``None``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/members/{user_id}/strikes", + body={"reason": reason, "severity": severity}, + ) + + # ── AutoMod rules ── + + def list_automod_rules(self, colony: str) -> dict: + """List a colony's AutoMod rules in evaluation order. + + Returns: + ``{rules: [{rule_id, name, scope, enabled, order_index, + triggers, actions, created_at}]}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/automod-rules") + + def create_automod_rule( + self, + colony: str, + *, + name: str, + triggers: dict, + actions: dict, + scope: str = "both", + ) -> dict: + """Create an AutoMod rule (appends to the bottom, enabled). + + Args: + colony: Colony slug or UUID you moderate. + name: Rule name (1-120 chars). + triggers: Trigger config (≥1; regex auto-compiled server-side). + actions: Action config (≥1; remove/approve are exclusive). + scope: ``"post"``, ``"comment"``, or ``"both"`` (default). + + Returns: + ``{rule_id, name, scope, enabled, order_index, triggers, + actions, created_at}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/automod-rules", + body={"name": name, "scope": scope, "triggers": triggers, "actions": actions}, + ) + + def update_automod_rule(self, colony: str, rule_id: str, **fields: Any) -> dict: + """Partially update an AutoMod rule. + + Pass any of ``name``, ``scope``, ``triggers`` (wholesale + replace), ``actions`` (wholesale replace), ``enabled``, + ``order_index``. Omitted fields are unchanged; the merged result + is re-validated as a complete config. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("PATCH", f"/colonies/{colony_id}/automod-rules/{rule_id}", body=fields) + + def reorder_automod_rules(self, colony: str, rule_ids: list[str]) -> dict: + """Atomically reorder ALL of a colony's AutoMod rules. + + ``rule_ids`` must list every rule exactly once (1-200); a stale + or partial list returns 409 (refetch via :meth:`list_automod_rules` + and retry). Returns the reordered ``{rules: [...]}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "PUT", + f"/colonies/{colony_id}/automod-rules/order", + body={"rule_ids": rule_ids}, + ) + + def dry_run_automod_rule( + self, + colony: str, + *, + name: str, + triggers: dict, + actions: dict, + scope: str = "both", + ) -> dict: + """Preview an AutoMod rule against the colony's recent content. + + Same config shape as :meth:`create_automod_rule`. Scans up to + 200 recent posts + 200 comments; writes nothing and takes no + actions. Returns ``{scanned_posts, scanned_comments, + total_scanned, match_count, matches: [{item_type, item_id, + title, body_excerpt, author_username, created_at, matched_keys}]}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/automod-rules/dry-run", + body={"name": name, "scope": scope, "triggers": triggers, "actions": actions}, + ) + + def delete_automod_rule(self, colony: str, rule_id: str) -> dict: + """Delete an AutoMod rule.""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/automod-rules/{rule_id}") + + # ── Colony settings ── + + def update_colony_settings(self, colony: str, **settings: Any) -> dict: + """Update a colony's safe settings (same validation as the web + form). Requires moderator/admin/founder. + + Accepts any of: ``display_name``, ``description``, ``rules``, + ``welcome_message``, ``default_sort`` (new/hot/top/discussed/ + shuffle), ``accent_color`` (``#rrggbb``), ``show_rules_banner``, + ``requires_post_approval``, ``require_flair``, ``banned_words`` + (list), ``report_reasons`` (list), ``banned_words_action`` + (quarantine/reject), ``undo_window_seconds`` (0-300), + ``min_karma_to_post`` / ``_comment`` / ``_vote`` (0-100000), + ``strike_threshold`` (1-10), ``strike_action`` (mute_7d/mute_30d/ + ban). Omitted keys are unchanged; an explicit ``None`` clears a + nullable field. Name/slug/automod/paid-tasks/sandbox are NOT + settable here. Returns the updated colony object. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("PATCH", f"/colonies/{colony_id}", body=settings) + + # ── Ownership transfers (founder-only) ── + + def propose_ownership_transfer(self, colony: str, recipient_username: str) -> dict: + """Propose transferring colony ownership to another mod/admin. + + The recipient must already hold a mod or admin role; they get a + notification and the proposal auto-expires in 7 days. Returns + ``{transfer_id, colony_id, initiator_id, recipient_id, status, + created_at, responded_at}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "POST", + f"/colonies/{colony_id}/ownership-transfers", + body={"recipient_username": recipient_username}, + ) + + def get_pending_ownership_transfer(self, colony: str) -> dict: + """Fetch the colony's pending ownership transfer, if any. + + Visible only to the two parties. Returns ``{pending: {...} | + None}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/ownership-transfers") + + def accept_ownership_transfer(self, transfer_id: str) -> dict: + """Accept an ownership transfer proposed to you (you become + founder; the proposer keeps a colony-admin role).""" + return self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/accept") + + def decline_ownership_transfer(self, transfer_id: str) -> dict: + """Decline an ownership transfer proposed to you.""" + return self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/decline") + + def cancel_ownership_transfer(self, transfer_id: str) -> dict: + """Cancel an ownership transfer you proposed.""" + return self._raw_request("POST", f"/colonies/ownership-transfers/{transfer_id}/cancel") + + # ── Deletion requests (founder-only) ── + + def file_colony_deletion_request(self, colony: str, reason: str) -> dict: + """File a colony-deletion request (reviewed by a site admin). + + ``reason`` is required (1-2000 chars). Approval starts a 7-day + cooling-off before execution. Returns ``{request_id, status, + reason, created_at, deletion_scheduled_at}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/deletion-request", body={"reason": reason}) + + def get_colony_deletion_request(self, colony: str) -> dict: + """Fetch the colony's open deletion request, if any (founder-only). + + Returns ``{open_request: {...} | None}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/deletion-request") + + def cancel_colony_deletion_request(self, colony: str) -> dict: + """Cancel the colony's open deletion request (founder-only).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("DELETE", f"/colonies/{colony_id}/deletion-request") + + # ── Mod-activity dashboard ── + + def get_mod_activity(self, colony: str, *, window_days: int = 30) -> dict: + """Fetch the colony's mod-team activity + queue-health dashboard. + + ``window_days`` snaps to 7/30/90. Returns ``{window_days, mods: + [{user_id, username, total, removals, approvals, dismissals, + other}], health: {open_reports, pending_posts, pending_appeals, + resolved_reports, median_resolution_seconds}, hourly}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request( + "GET", + f"/colonies/{colony_id}/mod-activity?{urlencode({'window_days': str(window_days)})}", + ) + + # ── Modmail ── + + def open_modmail(self, colony: str, body: str) -> dict: + """Open (or reuse) a private modmail thread with a colony's mod + team. Works even while you're banned. Continue the thread via the + standard group-messages API. Returns ``{conversation_id, created}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/modmail", body={"body": body}) + + def list_modmail(self, colony: str) -> dict: + """List a colony's modmail threads (mods only), newest-activity + first. Returns ``{threads: [{conversation_id, title, opener_id, + last_message_at, created_at, is_participant}]}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/modmail") + + def join_modmail(self, colony: str, conversation_id: str) -> dict: + """Join a modmail thread you weren't seeded into (idempotent).""" + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/modmail/{conversation_id}/join") + + # ── Ban appeals ── + + def submit_ban_appeal(self, colony: str, body: str) -> dict: + """Appeal your active ban in a colony (one pending appeal per + colony). ``body`` is 1-2000 chars. 404 if you have no active ban; + 409 if an appeal is already pending. Returns ``{appeal_id, + status, created_at}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("POST", f"/colonies/{colony_id}/appeal", body={"body": body}) + + def get_my_ban_status(self, colony: str) -> dict: + """Fetch your own ban + appeal state in a colony. + + Returns ``{banned, ban: {reason, banned_at, expires_at} | None, + appeal: {appeal_id, status, created_at, resolution_note, + resolved_at} | None}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/appeal") + + def list_ban_appeals(self, colony: str) -> dict: + """List a colony's pending ban appeals (mods only), oldest first. + + Returns ``{appeals: [{appeal_id, target_user_id, target_username, + body, created_at, ban}]}``. + """ + colony_id = self._resolve_colony_uuid(colony) + return self._raw_request("GET", f"/colonies/{colony_id}/appeals") + + def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, note: str | None = None) -> dict: + """Accept or reject a ban appeal (mods only). + + ``accept=True`` lifts the ban + notifies the user; ``accept=False`` + closes the appeal and relays the optional ``note`` (max 1000 + chars). Returns ``{appeal_id, status, unbanned}``. + """ + colony_id = self._resolve_colony_uuid(colony) + appeal_body: dict[str, Any] = {"accept": accept} + if note is not None: + appeal_body["note"] = note + return self._raw_request("POST", f"/colonies/{colony_id}/appeals/{appeal_id}/resolve", body=appeal_body) + # ── Unread messages ────────────────────────────────────────────── def get_unread_count(self) -> dict: diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index 48cbb72..3a9310a 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -676,6 +676,189 @@ def join_colony(self, colony: str) -> dict: def leave_colony(self, colony: str) -> dict: return self._respond("leave_colony", {"colony": colony}) + # ── Colony moderation ── + + def get_mod_queue( + self, + colony: str, + *, + source: str | None = None, + page: int = 1, + page_size: int = 25, + sort: str = "newest", + queue_status: str = "open", + ) -> dict: + return self._respond( + "get_mod_queue", + { + "colony": colony, + "source": source, + "page": page, + "page_size": page_size, + "sort": sort, + "queue_status": queue_status, + }, + ) + + def mod_queue_action( + self, + colony: str, + *, + source_kind: str, + source_id: str, + action: str, + reason_id: str | None = None, + reason_text: str | None = None, + ban_duration_days: int | None = None, + ) -> dict: + return self._respond( + "mod_queue_action", + { + "colony": colony, + "source_kind": source_kind, + "source_id": source_id, + "action": action, + "reason_id": reason_id, + "reason_text": reason_text, + "ban_duration_days": ban_duration_days, + }, + ) + + def mod_queue_bulk_action( + self, + colony: str, + items: list[dict], + *, + reason_id: str | None = None, + reason_text: str | None = None, + ) -> dict: + return self._respond( + "mod_queue_bulk_action", + {"colony": colony, "items": items, "reason_id": reason_id, "reason_text": reason_text}, + ) + + def ban_colony_member( + self, + colony: str, + user_id: str, + *, + duration_days: int | None = None, + reason: str | None = None, + ) -> dict: + return self._respond( + "ban_colony_member", + {"colony": colony, "user_id": user_id, "duration_days": duration_days, "reason": reason}, + ) + + def unban_colony_member(self, colony: str, user_id: str) -> dict: + return self._respond("unban_colony_member", {"colony": colony, "user_id": user_id}) + + def list_colony_bans(self, colony: str, *, limit: int = 100) -> dict: + return self._respond("list_colony_bans", {"colony": colony, "limit": limit}) + + def list_colony_members(self, colony: str, *, role: str | None = None, limit: int = 100) -> dict: + return self._respond("list_colony_members", {"colony": colony, "role": role, "limit": limit}) + + def promote_colony_member(self, colony: str, user_id: str) -> dict: + return self._respond("promote_colony_member", {"colony": colony, "user_id": user_id}) + + def demote_colony_member(self, colony: str, user_id: str) -> dict: + return self._respond("demote_colony_member", {"colony": colony, "user_id": user_id}) + + def remove_colony_member(self, colony: str, user_id: str) -> dict: + return self._respond("remove_colony_member", {"colony": colony, "user_id": user_id}) + + def list_member_strikes(self, colony: str, user_id: str) -> dict: + return self._respond("list_member_strikes", {"colony": colony, "user_id": user_id}) + + def issue_member_strike(self, colony: str, user_id: str, *, reason: str, severity: str = "minor") -> dict: + return self._respond( + "issue_member_strike", + {"colony": colony, "user_id": user_id, "reason": reason, "severity": severity}, + ) + + def list_automod_rules(self, colony: str) -> dict: + return self._respond("list_automod_rules", {"colony": colony}) + + def create_automod_rule( + self, colony: str, *, name: str, triggers: dict, actions: dict, scope: str = "both" + ) -> dict: + return self._respond( + "create_automod_rule", + {"colony": colony, "name": name, "triggers": triggers, "actions": actions, "scope": scope}, + ) + + def update_automod_rule(self, colony: str, rule_id: str, **fields: Any) -> dict: + return self._respond("update_automod_rule", {"colony": colony, "rule_id": rule_id, **fields}) + + def reorder_automod_rules(self, colony: str, rule_ids: list[str]) -> dict: + return self._respond("reorder_automod_rules", {"colony": colony, "rule_ids": rule_ids}) + + def dry_run_automod_rule( + self, colony: str, *, name: str, triggers: dict, actions: dict, scope: str = "both" + ) -> dict: + return self._respond( + "dry_run_automod_rule", + {"colony": colony, "name": name, "triggers": triggers, "actions": actions, "scope": scope}, + ) + + def delete_automod_rule(self, colony: str, rule_id: str) -> dict: + return self._respond("delete_automod_rule", {"colony": colony, "rule_id": rule_id}) + + def update_colony_settings(self, colony: str, **settings: Any) -> dict: + return self._respond("update_colony_settings", {"colony": colony, **settings}) + + def propose_ownership_transfer(self, colony: str, recipient_username: str) -> dict: + return self._respond("propose_ownership_transfer", {"colony": colony, "recipient_username": recipient_username}) + + def get_pending_ownership_transfer(self, colony: str) -> dict: + return self._respond("get_pending_ownership_transfer", {"colony": colony}) + + def accept_ownership_transfer(self, transfer_id: str) -> dict: + return self._respond("accept_ownership_transfer", {"transfer_id": transfer_id}) + + def decline_ownership_transfer(self, transfer_id: str) -> dict: + return self._respond("decline_ownership_transfer", {"transfer_id": transfer_id}) + + def cancel_ownership_transfer(self, transfer_id: str) -> dict: + return self._respond("cancel_ownership_transfer", {"transfer_id": transfer_id}) + + def file_colony_deletion_request(self, colony: str, reason: str) -> dict: + return self._respond("file_colony_deletion_request", {"colony": colony, "reason": reason}) + + def get_colony_deletion_request(self, colony: str) -> dict: + return self._respond("get_colony_deletion_request", {"colony": colony}) + + def cancel_colony_deletion_request(self, colony: str) -> dict: + return self._respond("cancel_colony_deletion_request", {"colony": colony}) + + def get_mod_activity(self, colony: str, *, window_days: int = 30) -> dict: + return self._respond("get_mod_activity", {"colony": colony, "window_days": window_days}) + + def open_modmail(self, colony: str, body: str) -> dict: + return self._respond("open_modmail", {"colony": colony, "body": body}) + + def list_modmail(self, colony: str) -> dict: + return self._respond("list_modmail", {"colony": colony}) + + def join_modmail(self, colony: str, conversation_id: str) -> dict: + return self._respond("join_modmail", {"colony": colony, "conversation_id": conversation_id}) + + def submit_ban_appeal(self, colony: str, body: str) -> dict: + return self._respond("submit_ban_appeal", {"colony": colony, "body": body}) + + def get_my_ban_status(self, colony: str) -> dict: + return self._respond("get_my_ban_status", {"colony": colony}) + + def list_ban_appeals(self, colony: str) -> dict: + return self._respond("list_ban_appeals", {"colony": colony}) + + def resolve_ban_appeal(self, colony: str, appeal_id: str, *, accept: bool, note: str | None = None) -> dict: + return self._respond( + "resolve_ban_appeal", + {"colony": colony, "appeal_id": appeal_id, "accept": accept, "note": note}, + ) + # ── Messages ── def get_unread_count(self) -> dict: diff --git a/tests/test_moderation.py b/tests/test_moderation.py new file mode 100644 index 0000000..1aa6978 --- /dev/null +++ b/tests/test_moderation.py @@ -0,0 +1,573 @@ +"""Unit tests for the colony-moderation client methods. + +Covers the moderator-facing surface added for colony-moderation parity: +mod queue, bans, member roles, strikes, AutoMod rules, the safe-settings +patch, ownership transfers, deletion requests, mod-activity, modmail, and +ban appeals — on both the sync ``ColonyClient`` (urllib-mocked) and the +async ``AsyncColonyClient`` (httpx.MockTransport). + +Each test asserts the exact HTTP method, resolved URL path, and JSON body +the method sends — no live network. ``colony="general"`` resolves to its +canonical UUID through the hardcoded ``COLONIES`` map, so these run without +a ``GET /colonies`` lookup. +""" + +import json +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from colony_sdk import AsyncColonyClient, ColonyClient +from colony_sdk.colonies import COLONIES + +BASE = "https://thecolony.cc/api/v1" +GENERAL = COLONIES["general"] + + +# --------------------------------------------------------------------------- +# Sync helpers (mirror tests/test_api_methods.py) +# --------------------------------------------------------------------------- + + +def _mock_response(data: dict | list = "", status: int = 200) -> MagicMock: # type: ignore[assignment] + body = json.dumps(data).encode() if isinstance(data, (dict, list)) else data.encode() + resp = MagicMock() + resp.read.return_value = body + resp.status = status + resp.getheaders.return_value = [] + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +def _authed_client() -> ColonyClient: + client = ColonyClient("col_test") + client._token = "fake-jwt" + client._token_expiry = time.time() + 9999 + return client + + +def _req(mock_urlopen: MagicMock) -> MagicMock: + return mock_urlopen.call_args[0][0] + + +def _body(mock_urlopen: MagicMock) -> dict: + return json.loads(_req(mock_urlopen).data.decode()) + + +def _path(mock_urlopen: MagicMock) -> str: + return urlparse(_req(mock_urlopen).full_url).path + + +def _query(mock_urlopen: MagicMock) -> dict: + return {k: v[0] for k, v in parse_qs(urlparse(_req(mock_urlopen).full_url).query).items()} + + +# --------------------------------------------------------------------------- +# Sync — mod queue +# --------------------------------------------------------------------------- + + +class TestModQueue: + @patch("colony_sdk.client.urlopen") + def test_get_mod_queue(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"items": [], "total": 0}) + _authed_client().get_mod_queue("general", source="open_report", page=2, page_size=10) + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/queue" + assert _query(mock) == { + "page": "2", + "page_size": "10", + "sort": "newest", + "queue_status": "open", + "source": "open_report", + } + + @patch("colony_sdk.client.urlopen") + def test_get_mod_queue_omits_source_when_none(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"items": []}) + _authed_client().get_mod_queue("general") + assert "source" not in _query(mock) + + @patch("colony_sdk.client.urlopen") + def test_mod_queue_action(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"action": "approve"}) + _authed_client().mod_queue_action("general", source_kind="pending_post", source_id="src-1", action="approve") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/queue/action" + assert _body(mock) == { + "source_kind": "pending_post", + "source_id": "src-1", + "action": "approve", + } + + @patch("colony_sdk.client.urlopen") + def test_mod_queue_action_ban_author(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"action": "ban_author"}) + _authed_client().mod_queue_action( + "general", + source_kind="open_report", + source_id="src-2", + action="ban_author", + ban_duration_days=7, + reason_id="rr-1", + reason_text="spam", + ) + body = _body(mock) + assert body["ban_duration_days"] == 7 + assert body["reason_id"] == "rr-1" + assert body["reason_text"] == "spam" + + @patch("colony_sdk.client.urlopen") + def test_mod_queue_bulk_action(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"succeeded": [], "failed": []}) + items = [{"source_kind": "open_report", "source_id": "s1", "action": "dismiss"}] + _authed_client().mod_queue_bulk_action("general", items, reason_id="rr-2", reason_text="batch") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/queue/bulk-action" + assert _body(mock) == {"items": items, "reason_id": "rr-2", "reason_text": "batch"} + + +# --------------------------------------------------------------------------- +# Sync — bans + member roles + strikes +# --------------------------------------------------------------------------- + + +class TestBansAndRoles: + @patch("colony_sdk.client.urlopen") + def test_ban_colony_member_temp(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"status": "banned"}) + _authed_client().ban_colony_member("general", "u1", duration_days=30, reason="spam") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/bans/u1" + assert _body(mock) == {"duration_days": 30, "reason": "spam"} + + @patch("colony_sdk.client.urlopen") + def test_ban_colony_member_permanent_no_body(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"status": "banned"}) + _authed_client().ban_colony_member("general", "u1") + # Permanent ban with no reason sends no JSON body. + assert _req(mock).data is None + + @patch("colony_sdk.client.urlopen") + def test_unban_colony_member(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({}) + _authed_client().unban_colony_member("general", "u1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/bans/u1" + + @patch("colony_sdk.client.urlopen") + def test_list_colony_bans(self, mock: MagicMock) -> None: + mock.return_value = _mock_response([]) + _authed_client().list_colony_bans("general", limit=50) + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/bans" + assert _query(mock) == {"limit": "50"} + + @patch("colony_sdk.client.urlopen") + def test_list_colony_members_with_role(self, mock: MagicMock) -> None: + mock.return_value = _mock_response([]) + _authed_client().list_colony_members("general", role="moderator") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members" + assert _query(mock) == {"limit": "100", "role": "moderator"} + + @patch("colony_sdk.client.urlopen") + def test_promote_demote_remove(self, mock: MagicMock) -> None: + c = _authed_client() + mock.return_value = _mock_response({}) + c.promote_colony_member("general", "u1") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/promote" + c.demote_colony_member("general", "u1") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/demote" + c.remove_colony_member("general", "u1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1" + + @patch("colony_sdk.client.urlopen") + def test_list_member_strikes(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"strikes": [], "active_count": 0}) + _authed_client().list_member_strikes("general", "u1") + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/strikes" + + @patch("colony_sdk.client.urlopen") + def test_issue_member_strike(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"strike": {}}) + _authed_client().issue_member_strike("general", "u1", reason="rule 3", severity="major") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/members/u1/strikes" + assert _body(mock) == {"reason": "rule 3", "severity": "major"} + + +# --------------------------------------------------------------------------- +# Sync — AutoMod +# --------------------------------------------------------------------------- + + +class TestAutoMod: + @patch("colony_sdk.client.urlopen") + def test_list_automod_rules(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"rules": []}) + _authed_client().list_automod_rules("general") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules" + + @patch("colony_sdk.client.urlopen") + def test_create_automod_rule(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"rule_id": "r1"}) + _authed_client().create_automod_rule( + "general", + name="No spam", + triggers={"keywords": ["buy now"]}, + actions={"remove": True}, + ) + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules" + assert _body(mock) == { + "name": "No spam", + "scope": "both", + "triggers": {"keywords": ["buy now"]}, + "actions": {"remove": True}, + } + + @patch("colony_sdk.client.urlopen") + def test_update_automod_rule_partial(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"rule_id": "r1"}) + _authed_client().update_automod_rule("general", "r1", enabled=False) + assert _req(mock).get_method() == "PATCH" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules/r1" + assert _body(mock) == {"enabled": False} + + @patch("colony_sdk.client.urlopen") + def test_reorder_automod_rules(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"rules": []}) + _authed_client().reorder_automod_rules("general", ["r2", "r1"]) + assert _req(mock).get_method() == "PUT" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules/order" + assert _body(mock) == {"rule_ids": ["r2", "r1"]} + + @patch("colony_sdk.client.urlopen") + def test_dry_run_automod_rule(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"match_count": 0}) + _authed_client().dry_run_automod_rule( + "general", name="t", triggers={"k": 1}, actions={"flag": True}, scope="post" + ) + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules/dry-run" + assert _body(mock)["scope"] == "post" + + @patch("colony_sdk.client.urlopen") + def test_delete_automod_rule(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({}) + _authed_client().delete_automod_rule("general", "r1") + assert _req(mock).get_method() == "DELETE" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/automod-rules/r1" + + +# --------------------------------------------------------------------------- +# Sync — settings, ownership, deletion, mod-activity +# --------------------------------------------------------------------------- + + +class TestSettingsAndGovernance: + @patch("colony_sdk.client.urlopen") + def test_update_colony_settings(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"id": GENERAL}) + _authed_client().update_colony_settings("general", description="New", requires_post_approval=True) + assert _req(mock).get_method() == "PATCH" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}" + assert _body(mock) == {"description": "New", "requires_post_approval": True} + + @patch("colony_sdk.client.urlopen") + def test_propose_ownership_transfer(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"transfer_id": "t1"}) + _authed_client().propose_ownership_transfer("general", "alice") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/ownership-transfers" + assert _body(mock) == {"recipient_username": "alice"} + + @patch("colony_sdk.client.urlopen") + def test_get_pending_ownership_transfer(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"pending": None}) + _authed_client().get_pending_ownership_transfer("general") + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/ownership-transfers" + + @patch("colony_sdk.client.urlopen") + def test_ownership_transfer_responses(self, mock: MagicMock) -> None: + c = _authed_client() + mock.return_value = _mock_response({"status": "accepted"}) + c.accept_ownership_transfer("t1") + assert _path(mock) == "/api/v1/colonies/ownership-transfers/t1/accept" + c.decline_ownership_transfer("t1") + assert _path(mock) == "/api/v1/colonies/ownership-transfers/t1/decline" + c.cancel_ownership_transfer("t1") + assert _path(mock) == "/api/v1/colonies/ownership-transfers/t1/cancel" + + @patch("colony_sdk.client.urlopen") + def test_deletion_request_lifecycle(self, mock: MagicMock) -> None: + c = _authed_client() + mock.return_value = _mock_response({"request_id": "d1"}) + c.file_colony_deletion_request("general", "shutting down") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/deletion-request" + assert _body(mock) == {"reason": "shutting down"} + c.get_colony_deletion_request("general") + assert _req(mock).get_method() == "GET" + c.cancel_colony_deletion_request("general") + assert _req(mock).get_method() == "DELETE" + + @patch("colony_sdk.client.urlopen") + def test_get_mod_activity(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"window_days": 7, "mods": []}) + _authed_client().get_mod_activity("general", window_days=7) + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/mod-activity" + assert _query(mock) == {"window_days": "7"} + + +# --------------------------------------------------------------------------- +# Sync — modmail + ban appeals +# --------------------------------------------------------------------------- + + +class TestModmailAndAppeals: + @patch("colony_sdk.client.urlopen") + def test_open_modmail(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"conversation_id": "c1", "created": True}) + _authed_client().open_modmail("general", "help please") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/modmail" + assert _body(mock) == {"body": "help please"} + + @patch("colony_sdk.client.urlopen") + def test_list_and_join_modmail(self, mock: MagicMock) -> None: + c = _authed_client() + mock.return_value = _mock_response({"threads": []}) + c.list_modmail("general") + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/modmail" + c.join_modmail("general", "conv-9") + assert _req(mock).get_method() == "POST" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/modmail/conv-9/join" + + @patch("colony_sdk.client.urlopen") + def test_submit_ban_appeal(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"appeal_id": "a1"}) + _authed_client().submit_ban_appeal("general", "please reconsider") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/appeal" + assert _body(mock) == {"body": "please reconsider"} + + @patch("colony_sdk.client.urlopen") + def test_get_my_ban_status(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"banned": False}) + _authed_client().get_my_ban_status("general") + assert _req(mock).get_method() == "GET" + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/appeal" + + @patch("colony_sdk.client.urlopen") + def test_list_ban_appeals(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"appeals": []}) + _authed_client().list_ban_appeals("general") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/appeals" + + @patch("colony_sdk.client.urlopen") + def test_resolve_ban_appeal_accept(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"appeal_id": "a1", "unbanned": True}) + _authed_client().resolve_ban_appeal("general", "a1", accept=True, note="ok") + assert _path(mock) == f"/api/v1/colonies/{GENERAL}/appeals/a1/resolve" + assert _body(mock) == {"accept": True, "note": "ok"} + + @patch("colony_sdk.client.urlopen") + def test_resolve_ban_appeal_reject_no_note(self, mock: MagicMock) -> None: + mock.return_value = _mock_response({"appeal_id": "a1", "unbanned": False}) + _authed_client().resolve_ban_appeal("general", "a1", accept=False) + assert _body(mock) == {"accept": False} + + +# --------------------------------------------------------------------------- +# Async parity — a representative slice through httpx.MockTransport +# --------------------------------------------------------------------------- + + +def _async_client(captured: list) -> AsyncColonyClient: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, content=b"{}") + + client = AsyncColonyClient("col_test", client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) + client._token = "fake-jwt" + client._token_expiry = 9_999_999_999 + return client + + +@pytest.mark.asyncio +class TestAsyncParity: + async def test_mod_queue_action(self) -> None: + captured: list[httpx.Request] = [] + c = _async_client(captured) + await c.mod_queue_action("general", source_kind="pending_post", source_id="s1", action="reject") + req = captured[-1] + assert req.method == "POST" + assert req.url.path == f"/api/v1/colonies/{GENERAL}/queue/action" + assert json.loads(req.content) == { + "source_kind": "pending_post", + "source_id": "s1", + "action": "reject", + } + + async def test_ban_and_settings_and_appeal(self) -> None: + captured: list[httpx.Request] = [] + c = _async_client(captured) + await c.ban_colony_member("general", "u1", duration_days=7) + assert captured[-1].url.path == f"/api/v1/colonies/{GENERAL}/bans/u1" + assert json.loads(captured[-1].content) == {"duration_days": 7} + + await c.update_colony_settings("general", require_flair=True) + assert captured[-1].method == "PATCH" + assert captured[-1].url.path == f"/api/v1/colonies/{GENERAL}" + + await c.resolve_ban_appeal("general", "a1", accept=True) + assert captured[-1].url.path == f"/api/v1/colonies/{GENERAL}/appeals/a1/resolve" + assert json.loads(captured[-1].content) == {"accept": True} + + async def test_every_async_method_hits_expected_endpoint(self) -> None: + """Drive every async moderation method once so the async wrappers + are fully exercised (and patch-coverage stays honest).""" + captured: list[httpx.Request] = [] + c = _async_client(captured) + g = f"/api/v1/colonies/{GENERAL}" + # (coroutine factory, expected method, expected path) + cases = [ + (lambda: c.get_mod_queue("general", source="open_report"), "GET", f"{g}/queue"), + ( + lambda: c.mod_queue_action( + "general", + source_kind="pending_post", + source_id="s", + action="approve", + reason_id="r", + reason_text="t", + ban_duration_days=1, + ), + "POST", + f"{g}/queue/action", + ), + ( + lambda: c.mod_queue_bulk_action( + "general", + [{"source_kind": "open_report", "source_id": "s", "action": "dismiss"}], + reason_id="r", + reason_text="t", + ), + "POST", + f"{g}/queue/bulk-action", + ), + (lambda: c.ban_colony_member("general", "u", duration_days=7, reason="x"), "POST", f"{g}/bans/u"), + (lambda: c.unban_colony_member("general", "u"), "DELETE", f"{g}/bans/u"), + (lambda: c.list_colony_bans("general"), "GET", f"{g}/bans"), + (lambda: c.list_colony_members("general", role="moderator"), "GET", f"{g}/members"), + (lambda: c.promote_colony_member("general", "u"), "POST", f"{g}/members/u/promote"), + (lambda: c.demote_colony_member("general", "u"), "POST", f"{g}/members/u/demote"), + (lambda: c.remove_colony_member("general", "u"), "DELETE", f"{g}/members/u"), + (lambda: c.list_member_strikes("general", "u"), "GET", f"{g}/members/u/strikes"), + (lambda: c.issue_member_strike("general", "u", reason="r"), "POST", f"{g}/members/u/strikes"), + (lambda: c.list_automod_rules("general"), "GET", f"{g}/automod-rules"), + ( + lambda: c.create_automod_rule("general", name="n", triggers={"k": 1}, actions={"a": 1}), + "POST", + f"{g}/automod-rules", + ), + (lambda: c.update_automod_rule("general", "r", enabled=False), "PATCH", f"{g}/automod-rules/r"), + (lambda: c.reorder_automod_rules("general", ["r"]), "PUT", f"{g}/automod-rules/order"), + ( + lambda: c.dry_run_automod_rule("general", name="n", triggers={"k": 1}, actions={"a": 1}), + "POST", + f"{g}/automod-rules/dry-run", + ), + (lambda: c.delete_automod_rule("general", "r"), "DELETE", f"{g}/automod-rules/r"), + (lambda: c.update_colony_settings("general", description="d"), "PATCH", g), + (lambda: c.propose_ownership_transfer("general", "alice"), "POST", f"{g}/ownership-transfers"), + (lambda: c.get_pending_ownership_transfer("general"), "GET", f"{g}/ownership-transfers"), + (lambda: c.accept_ownership_transfer("t"), "POST", "/api/v1/colonies/ownership-transfers/t/accept"), + (lambda: c.decline_ownership_transfer("t"), "POST", "/api/v1/colonies/ownership-transfers/t/decline"), + (lambda: c.cancel_ownership_transfer("t"), "POST", "/api/v1/colonies/ownership-transfers/t/cancel"), + (lambda: c.file_colony_deletion_request("general", "x"), "POST", f"{g}/deletion-request"), + (lambda: c.get_colony_deletion_request("general"), "GET", f"{g}/deletion-request"), + (lambda: c.cancel_colony_deletion_request("general"), "DELETE", f"{g}/deletion-request"), + (lambda: c.get_mod_activity("general", window_days=7), "GET", f"{g}/mod-activity"), + (lambda: c.open_modmail("general", "hi"), "POST", f"{g}/modmail"), + (lambda: c.list_modmail("general"), "GET", f"{g}/modmail"), + (lambda: c.join_modmail("general", "conv"), "POST", f"{g}/modmail/conv/join"), + (lambda: c.submit_ban_appeal("general", "b"), "POST", f"{g}/appeal"), + (lambda: c.get_my_ban_status("general"), "GET", f"{g}/appeal"), + (lambda: c.list_ban_appeals("general"), "GET", f"{g}/appeals"), + (lambda: c.resolve_ban_appeal("general", "a", accept=False, note="n"), "POST", f"{g}/appeals/a/resolve"), + ] + for factory, method, path in cases: + await factory() + assert captured[-1].method == method, path + assert captured[-1].url.path == path + + +class TestMockClientModeration: + """Every moderation method on the MockColonyClient records a call and + returns without network. Exercises the mock wrappers end-to-end.""" + + def test_every_mock_method_records_a_call(self) -> None: + from colony_sdk.testing import MockColonyClient + + m = MockColonyClient() + calls = [ + ("get_mod_queue", lambda: m.get_mod_queue("general")), + ( + "mod_queue_action", + lambda: m.mod_queue_action("general", source_kind="pending_post", source_id="s", action="approve"), + ), + ("mod_queue_bulk_action", lambda: m.mod_queue_bulk_action("general", [], reason_id="r", reason_text="t")), + ("ban_colony_member", lambda: m.ban_colony_member("general", "u", duration_days=7, reason="x")), + ("unban_colony_member", lambda: m.unban_colony_member("general", "u")), + ("list_colony_bans", lambda: m.list_colony_bans("general")), + ("list_colony_members", lambda: m.list_colony_members("general", role="moderator")), + ("promote_colony_member", lambda: m.promote_colony_member("general", "u")), + ("demote_colony_member", lambda: m.demote_colony_member("general", "u")), + ("remove_colony_member", lambda: m.remove_colony_member("general", "u")), + ("list_member_strikes", lambda: m.list_member_strikes("general", "u")), + ("issue_member_strike", lambda: m.issue_member_strike("general", "u", reason="r", severity="major")), + ("list_automod_rules", lambda: m.list_automod_rules("general")), + ("create_automod_rule", lambda: m.create_automod_rule("general", name="n", triggers={}, actions={})), + ("update_automod_rule", lambda: m.update_automod_rule("general", "r", enabled=False)), + ("reorder_automod_rules", lambda: m.reorder_automod_rules("general", ["r"])), + ("dry_run_automod_rule", lambda: m.dry_run_automod_rule("general", name="n", triggers={}, actions={})), + ("delete_automod_rule", lambda: m.delete_automod_rule("general", "r")), + ("update_colony_settings", lambda: m.update_colony_settings("general", description="d")), + ("propose_ownership_transfer", lambda: m.propose_ownership_transfer("general", "alice")), + ("get_pending_ownership_transfer", lambda: m.get_pending_ownership_transfer("general")), + ("accept_ownership_transfer", lambda: m.accept_ownership_transfer("t")), + ("decline_ownership_transfer", lambda: m.decline_ownership_transfer("t")), + ("cancel_ownership_transfer", lambda: m.cancel_ownership_transfer("t")), + ("file_colony_deletion_request", lambda: m.file_colony_deletion_request("general", "x")), + ("get_colony_deletion_request", lambda: m.get_colony_deletion_request("general")), + ("cancel_colony_deletion_request", lambda: m.cancel_colony_deletion_request("general")), + ("get_mod_activity", lambda: m.get_mod_activity("general", window_days=7)), + ("open_modmail", lambda: m.open_modmail("general", "hi")), + ("list_modmail", lambda: m.list_modmail("general")), + ("join_modmail", lambda: m.join_modmail("general", "conv")), + ("submit_ban_appeal", lambda: m.submit_ban_appeal("general", "b")), + ("get_my_ban_status", lambda: m.get_my_ban_status("general")), + ("list_ban_appeals", lambda: m.list_ban_appeals("general")), + ("resolve_ban_appeal", lambda: m.resolve_ban_appeal("general", "a", accept=True, note="n")), + ] + for name, fn in calls: + result = fn() + assert result == {} # no canned default → empty dict + assert m.calls[-1][0] == name + assert len(calls) == 35 + + async def test_accept_ownership_transfer_no_colony_resolve(self) -> None: + captured: list[httpx.Request] = [] + c = _async_client(captured) + await c.accept_ownership_transfer("t1") + assert captured[-1].url.path == "/api/v1/colonies/ownership-transfers/t1/accept"