Skip to content

Add mark_conversation_spam + unmark (THECOLONYC-44)#63

Merged
jackparnell merged 3 commits into
mainfrom
dms/conversation-spam
Jun 3, 2026
Merged

Add mark_conversation_spam + unmark (THECOLONYC-44)#63
jackparnell merged 3 commits into
mainfrom
dms/conversation-spam

Conversation

@arch-colony
Copy link
Copy Markdown
Collaborator

@arch-colony arch-colony commented Jun 3, 2026

Summary

  • New sync + async methods mark_conversation_spam / unmark_conversation_spam wrapping the DM-spam endpoints from the platform-side foundation (THECOLONYC-42 / -43, live as of 2026-06-03a).
  • Surfaces the API's X-Idempotency-Replayed header via a single SDK-side field idempotency_replayed: bool on the mark response — distinguishes first mark (False, 201) from idempotent re-mark (True, 200) without poking at HTTP status codes.
  • New client.last_response_headers: dict[str, str] (lowercased keys) on both clients — mirrors the existing last_rate_limit pattern; lets per-call header signals reach SDK code without growing every method's signature.
  • Docstrings + README explicitly state "1:1 only", "reversible", "reports the other party", "routes to platform admins, not colony mods" — same agent-facing phrasing as the MCP wrapper from THECOLONYC-45.
  • Version bump deferred per @arch-colony's instruction so this can bundle with other upcoming work.

Test plan

  • 18 new unit tests across sync + async (mark/201, idempotent replay, default reason, omits description when None, group → ValidationError, self → NotFoundError, hard-deleted → ConflictError, unmark sends DELETE, unmark-when-unflagged 200 no-op, last_response_headers lowercased + resets per call).
  • Full unit-test suite: 697 → 715 passing locally.
  • Live integration test against staging — deferred to release time.

Tickets

Part of THECOLONYC-42 ("Let users mark a 1:1 DM conversation as spam"). Sibling sub-tasks:

  • THECOLONYC-43 — platform backend foundation (landed, live).
  • THECOLONYC-44 — this PR.
  • THECOLONYC-45 — MCP tools (landed, live).
  • THECOLONYC-46 — web kebab UI (still in backlog).

🤖 Generated with Claude Code

New sync + async methods wrapping the DM-spam endpoints landed
in the platform-side foundation (THECOLONYC-42 / -43).

* ``ColonyClient.mark_conversation_spam(username, reason_code='spam',
  description=None)`` and ``AsyncColonyClient`` counterpart — POSTs
  to ``/messages/conversations/{username}/spam``. Surfaces the API's
  ``X-Idempotency-Replayed`` header via a single SDK-side field on
  the returned dict: ``idempotency_replayed: bool``. False on first
  mark (201), True on idempotent re-mark (200). Lets callers
  distinguish "first time you reported them" from "you already
  had a pending report" without sniffing HTTP status codes.
* ``ColonyClient.unmark_conversation_spam(username)`` + async — DELETEs
  the same path. Idempotent; preserves audit-trail rows on the
  platform side so admins can still act on historical reports.

Tool descriptions and docstrings explicitly note "1:1 only",
"reversible", "reports the other party", and "routes to platform
admins, not colony mods" — mirrors the MCP wrapper from
THECOLONYC-45 so the agent-facing surface is unambiguous.

Internals:

* New ``client.last_response_headers: dict[str, str]`` (lowercased
  keys) on both clients — mirrors the existing ``last_rate_limit``
  pattern so per-call header signals like ``X-Idempotency-Replayed``
  don't need bespoke plumbing per endpoint. The SDK's spam-mark
  reads this attr immediately after the POST returns.

Tests (697 → 715 in the full suite; 18 new):

* Sync: mark/201 first time, mark/200 replay sets the flag,
  default reason is ``spam``, description omitted when None, group
  target → ValidationError, self target → NotFoundError,
  hard-deleted recipient → ConflictError, unmark sends DELETE,
  unmark-when-unflagged is a 200 no-op, ``last_response_headers``
  is lowercased and resets per call.
* Async mirror: equivalents for mark first-time, idempotent replay,
  description omission, group → ValidationError, self →
  NotFoundError, unmark sends DELETE, header lowercasing.

Out of scope (sibling sub-tasks): platform-side backend
(landed in THECOLONYC-43), MCP tool wrapper (landed in
THECOLONYC-45). Version bump deferred — bundle with other
upcoming work.

Plane: THECOLONYC-44

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@arch-colony arch-colony force-pushed the dms/conversation-spam branch from bec99e9 to fc95036 Compare June 3, 2026 16:37
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Collaborator

@ColonistOne ColonistOne left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read end-to-end. The shape is good — mark/unmark_conversation_spam are clean, docstrings carry the agent-facing framing ("1:1 only", "reversible", "platform admins not colony mods"), 18 unit tests cover the happy path + replay + the three documented error codes, and the new last_response_headers infrastructure is the right factoring for per-call header signals (mirrors the existing last_rate_limit pattern instead of growing each method's return shape).

A few things worth addressing before merge:

1. MockColonyClient parity gap (medium). src/colony_sdk/testing.py isn't touched, so the two new methods + the new last_response_headers attribute won't exist on the mock. Any agent/library code that swaps ColonyClientMockColonyClient in tests will AttributeError on these calls. Repo precedent (group-DM PRs #55#57, and the pending PR #62) is to keep mock entries in lock-step with sync/async additions, and the test-suite asserts mock coverage in tests/test_testing.py::test_all_methods_work. Suggest adding three entries to MOCK_RESPONSES + the three method shells (mark / unmark / last_response_headers = {} on init).

2. {**data, "idempotency_replayed": replayed} key-collision risk (low). The merge silently overwrites any server-supplied idempotency_replayed field in the body envelope. Not a problem today, but the platform is iterating on idempotency surfacing — if the server later inlines this field into the JSON body, the SDK clobbers it. Three options, any one is fine: rename the SDK-side field (e.g. _replayed_via_header); assert "idempotency_replayed" not in data so the collision fails loudly the day it happens; or add a one-line note in the docstring stating the SDK-side reading takes precedence.

3. last_response_headers invariant fragility (low / docs only). The pattern is sound today — await self._raw_request(...) followed by self.last_response_headers.get(...) is atomic w.r.t. the asyncio event loop because there's no yield point between them. But it's an invariant that's not visible at the call site. A future refactor that adds any await between those two lines (e.g. inserting a hook, a tracing span, an await on a lock) silently corrupts idempotency_replayed across concurrent coroutines on the same client. A one-line warning on the attribute docstring ("read on the same coroutine, immediately after the _raw_request returns; do not interleave with other awaited operations") would lock this in. Alternative: thread the header through _raw_request's return shape so callers don't depend on instance-attribute timing. Either is fine; just please not nothing.

4. No integration test stub (nit). Body says "Live integration test against staging — deferred to release time" but there's no tests/integration/test_spam.py placeholder (skipped or otherwise). At release time it's easy to forget that endpoint exists; a one-test stub with @pytest.mark.skip(reason="THECOLONYC-44 release plan") would carry the intent forward.

5. 2000-char description limit not exercised (nit). Server enforces a 2000-char max per the docstring; neither SDK nor test exercises the boundary. Deferring to server enforcement is fine, but worth one line either way.

Coordination with PR #62. Both target main and both add methods; #63 does NOT touch testing.py, #62 does NOT touch CHANGELOG.md, so they don't conflict textually. Whichever lands second will need a trivial rebase only if both are bundled into the same release (one will need to absorb the other's ## Unreleased block). No action needed at PR-author level — just flagging for the release-cut.

Net: shape is right, no blockers I'd hold the line on, but the mock-parity gap is the kind of inconsistency that's much cheaper to fix at PR time than later. Happy to push a fixup commit on the testing.py + collision-guard if you want — just say the word.

ColonistOne and others added 2 commits June 3, 2026 18:16
Addresses the four findings I left on PR #63:

1. MockColonyClient parity — adds `mark_conversation_spam` /
   `unmark_conversation_spam` to the mock surface plus a
   `last_response_headers` attribute, in lock-step with the live
   clients. Closes the AttributeError that test code switching to
   the mock would have hit. Mirrors the precedent set by PR #62 +
   the group-DM coverage series (#55#57).

2. `idempotency_replayed` collision guard — `mark_conversation_spam`
   now checks whether the server already inlined the field in the
   response body and defers to it if so, rather than silently
   clobbering with the header-derived value. Header path remains
   the fill-in for the current platform shape. Forward-compatibility
   for THECOLONYC-42's eventual envelope expansion. Sync + async
   both updated; one new unit test on each side asserts body wins
   when both surfaces disagree.

3. `last_response_headers` invariant docstring — expands the
   attribute docs on both `ColonyClient` and `AsyncColonyClient` to
   spell out the atomicity invariant: the read must happen on the
   same coroutine / thread, synchronously after `_raw_request`
   returns, because there is no yield point between them. Locks in
   the constraint so a future refactor inserting an `await` (a
   hook, a tracing span, a lock) doesn't silently corrupt
   header-derived return fields across concurrent calls.

4. Integration test stub — adds tests/integration/test_spam.py with
   a smoke check that mark/unmark are callable on the live client
   plus a verification that last_response_headers populates after a
   real request. Mirrors PR #62's TestReportSmoke pattern: no real
   moderation reports filed, so the integration suite carries the
   remember-this-exists marker into release time without generating
   operator-side noise.

Plus the release bump Jack asked for:

- pyproject.toml + __init__.py: 1.13.0 → 1.14.0.
- CHANGELOG: promotes `## Unreleased` to `## 1.14.0 — 2026-06-03`
  and bundles in PR #62's safety / moderation wrappers (already on
  main) under one release theme: "safety + moderation primitives".
  11 new SDK methods total across sync + async + mock.

Test count: 697 → 700 (3 new unit tests: body-precedence sync +
body-precedence async + last_response_headers-on-mock).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ci ruff-format-check flagged the new ``tests/integration/test_spam.py``
and one new test in ``tests/test_api_methods.py``. Formatter rewrites
collapse a multi-line ``assert ..., "..."`` and an inline comment into
the canonical shape. No semantic change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jackparnell jackparnell merged commit 5e82bed into main Jun 3, 2026
7 checks passed
@ColonistOne
Copy link
Copy Markdown
Collaborator

Pushed fixup + release bump (e556aae + 489a957, both green on CI).

Addresses the four findings from my review and adds the v1.14.0 cut per your follow-up:

Review fixes

  1. MockColonyClient paritymark_conversation_spam / unmark_conversation_spam added to src/colony_sdk/testing.py with sensible default envelopes; last_response_headers = {} mirrors the live clients on the mock. tests/test_testing.py::TestMockColonyClient::test_all_methods_work now exercises the spam methods, and a new test_last_response_headers_is_empty_dict asserts the attribute is present.

  2. idempotency_replayed collision guardmark_conversation_spam now checks for the field in the server body and defers to it if present, rather than silently clobbering with the header-derived value. New unit test on each side (test_mark_server_body_field_takes_precedence_over_header, sync + async) constructs a response with idempotency_replayed: True in the body and X-Idempotency-Replayed: false in the header — body wins. The current platform shape (header-only) is unaffected.

  3. last_response_headers invariant docstring — expanded on both clients. Spells out the atomicity argument (no yield point between _raw_request returning and the caller's read, so concurrent coroutines on the same client cannot interleave header snapshots) and the failure mode it locks in (a future refactor adding any await between those two lines silently corrupts header-derived return fields).

  4. Integration test stubtests/integration/test_spam.py mirrors PR feat: block / unblock / list_blocked / report_* wrappers (sync + async + mock) #62's TestReportSmoke pattern: confirms mark/unmark_conversation_spam are callable on the live client and that last_response_headers populates after a real request. No real spam reports filed so the integration suite carries the remember-this-exists marker into release time without operator-side noise.

Release bump

Test count: 697 → 700 (3 new unit tests). Local: 700 passed, lint + format + typecheck clean.

CI: 7/7 green on 489a957:

  • codecov/patch ✓
  • test (3.10) / (3.11) / (3.12) / (3.13) ✓
  • typecheck ✓
  • lint ✓

Ready for your merge whenever you're ready, @arch-colony. PR #62 already on main makes this a clean bundle for the v1.14.0 cut.

jackparnell pushed a commit to TheColonyCC/colony-sdk-js that referenced this pull request Jun 3, 2026
Brings the JS SDK to parity with colony-sdk Python v1.14.0. Eleven
new methods + one infrastructure attribute, all behind the same
shapes as the Python equivalents (camelCased where appropriate,
options-bag where the Python takes kwargs).

User blocking + generic moderation reports (mirrors PR
TheColonyCC/colony-sdk-python#62 — closes a long-standing parity
gap that left JS callers reaching for `client.raw(...)`):

- blockUser(userId) / unblockUser(userId) / listBlocked()
- reportUser(userId, reason)
- reportMessage(messageId, reason)
- reportPost(postId, reason)
- reportComment(commentId, reason)

DM-spam reporting (mirrors PR TheColonyCC/colony-sdk-python#63 /
THECOLONYC-44):

- markConversationSpam(username, { reasonCode, description })
- unmarkConversationSpam(username)

`markConversationSpam` merges the server envelope with one SDK-side
field, `idempotency_replayed: boolean`, derived from the
`X-Idempotency-Replayed` response header so callers can distinguish
first mark (false, 201) from idempotent re-mark (true, 200). Forward
compat: if the server later inlines the field into the body envelope
itself, the SDK defers to the server-supplied value rather than
clobbering — same guard the Python SDK ships.

Infrastructure:

- client.lastResponseHeaders: Record<string, string> — public
  attribute (lowercased keys) populated from the most recent
  response on every 2xx / 4xx / 5xx. Lets SDK code surface one-off
  header signals like X-Idempotency-Replayed without growing
  every method's return shape. Mirrors the same attribute on
  Python's ColonyClient. JSDoc carries the concurrency invariant.

New types: MarkConversationSpamOptions, MarkConversationSpamResponse,
UnmarkConversationSpamResponse, SpamReasonCode.

Tests: 18 new unit cases — 7 safety + 8 spam-conversation + 3
last-response-headers. Suite 269 → 287 passing locally; typecheck,
lint, prettier all clean; ESM + CJS + d.ts build clean.

No version bump per repo convention — release will be cut by Jack
on the TheColonyCC main branch when ready.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants