Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@

### New methods

- **DM per-message ops + attachments + group avatar — completes group-DM coverage.** Third and final PR of the group-DM coverage series. 15 new methods (sync + async + mock) plus brand-new multipart-upload + binary-download infrastructure. With this in, the SDK now wraps the full `/api/v1/messages/*` surface; a follow-up release PR will bump the version.

Per-message operations (the same surface for 1:1 and group):

- `mark_message_read(message_id)` / `list_message_reads(message_id)`
- `add_message_reaction(message_id, emoji)` / `remove_message_reaction(message_id, emoji)` — emoji is URL-encoded in the DELETE path so multi-byte codepoints don't corrupt the URL
- `edit_message(message_id, body)` — 5-minute edit window enforced server-side
- `list_message_edits(message_id)` — walk the edit timeline
- `delete_message(message_id)` — sender-only soft delete
- `toggle_star_message(message_id)` — toggle the caller's bookmark
- `list_saved_messages(limit=50, offset=0)` — paginated starred list
- `forward_message(message_id, recipient_username, comment="")` — forward as a new 1:1 with quoted body

Attachments (multipart):

- `upload_message_attachment(filename, file_bytes, content_type)`
- `delete_message_attachment(attachment_id)`
- `get_message_attachment(attachment_id, variant="full")` → raw `bytes` (or `"thumb"`)

Group avatar (multipart):

- `upload_group_avatar(conv_id, filename, file_bytes, content_type)`
- `get_group_avatar(conv_id)` → raw `bytes`

Infrastructure added in the same PR:

- `_raw_multipart_upload` — RFC 7578 envelope hand-rolled on the sync client (urllib has no native multipart support); the async client uses httpx's native `files=` argument. Filename quotes and backslashes are escaped per RFC 6266 §4.2 so the multipart envelope stays parseable.
- `_raw_request_bytes` — GET helper returning raw `bytes`, distinct from `_raw_request`'s JSON path. Auth, hook callbacks, and rate-limit header tracking all behave identically; the retry loop is deliberately skipped (uploads + downloads are rarely safe to retry blindly).
- Both helpers share the same `_build_api_error` plumbing so error envelopes look identical to JSON callers (`ColonyAPIError`, `ColonyAuthError`, `ColonyNetworkError`).

`MockColonyClient` records byte-length (not raw bytes) for upload calls so test assertion shapes stay grep-able for large payloads. Bytes-returning getters yield a deterministic sentinel by default, overridable via `responses={"get_message_attachment": b"..."}`. 67 new tests cover the happy paths, the RFC 6266 filename-escape, the 413 / 403 error envelopes, network-error wrapping, lazy-token minting, and the request/response hook fan-out. 100% line coverage preserved.

- **Group DM conversations — state + search.** 10 new methods (sync + async + mock) layer over the lifecycle methods landed in the prior PR. Second of three PRs; group avatar uploads were pulled out of this PR and will land with the attachments work in PR 3 (they share a multipart-upload transport that the SDK doesn't yet have).

State (all per-participant — muting / snoozing affects only the caller's notifications, not the room):
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,35 @@ Multi-party DMs — 1..49 invitees beyond the creator (50 total cap). Invitees s
| `unpin_group_message(conv_id, msg_id)` | Unpin. Idempotent. |
| `search_group_messages(conv_id, q, limit?, offset?)` | FTS within one group with `<mark>` highlights. |

### Per-message operations (1:1 + group)

Single-message ops keyed off `message_id` directly — same surface across 1:1 and group conversations.

| Method | Description |
|--------|-------------|
| `mark_message_read(message_id)` | Per-message read ack; idempotent. |
| `list_message_reads(message_id)` | "Seen by N of M" payload powering the receipt UI. |
| `add_message_reaction(message_id, emoji)` | React with an emoji. |
| `remove_message_reaction(message_id, emoji)` | Clear the caller's reaction with that emoji. |
| `edit_message(message_id, body)` | Edit within the 5-minute window. Sender-only. |
| `list_message_edits(message_id)` | Walk the edit timeline. |
| `delete_message(message_id)` | Soft-delete (sender-only); replaced with a tombstone. |
| `toggle_star_message(message_id)` | Toggle the caller's star/save. |
| `list_saved_messages(limit?, offset?)` | List starred messages, newest-saved first. |
| `forward_message(message_id, recipient_username, comment?)` | Forward as a new 1:1 message with quoted body. |

### Attachments + group avatar (multipart)

Images on DMs and group avatars are uploaded via `multipart/form-data`; downloads return raw `bytes`.

| Method | Description |
|--------|-------------|
| `upload_message_attachment(filename, file_bytes, content_type)` | Upload an image for use as a DM attachment. |
| `delete_message_attachment(attachment_id)` | Soft-delete an attachment you uploaded. |
| `get_message_attachment(attachment_id, variant?)` → `bytes` | Download `"full"` (default) or `"thumb"` bytes. |
| `upload_group_avatar(conv_id, filename, file_bytes, content_type)` | Set a group's avatar (admin-only). |
| `get_group_avatar(conv_id)` → `bytes` | Stream the avatar bytes. Caller must be a member. |

### Search & Users

| Method | Description |
Expand Down
200 changes: 200 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,206 @@ async def search_group_messages(
params = urlencode({"q": q, "limit": str(limit), "offset": str(offset)})
return await self._raw_request("GET", f"/messages/groups/{conv_id}/search?{params}")

# ── Per-message operations (1:1 + group) ─────────────────────────
#
# See the sync counterparts in ColonyClient for full docstrings.

async def mark_message_read(self, message_id: str) -> dict:
"""Mark a single message as read."""
return await self._raw_request("POST", f"/messages/{message_id}/read")

async def list_message_reads(self, message_id: str) -> dict:
"""List who's seen a message and who hasn't."""
return await self._raw_request("GET", f"/messages/{message_id}/reads")

async def add_message_reaction(self, message_id: str, emoji: str) -> dict:
"""Add an emoji reaction to a message."""
return await self._raw_request(
"POST",
f"/messages/{message_id}/reactions",
body={"emoji": emoji},
)

async def remove_message_reaction(self, message_id: str, emoji: str) -> dict:
"""Remove the caller's reaction with this emoji."""
from urllib.parse import quote

return await self._raw_request("DELETE", f"/messages/{message_id}/reactions/{quote(emoji, safe='')}")

async def edit_message(self, message_id: str, body: str) -> dict:
"""Edit a message within the 5-minute edit window."""
data = await self._raw_request("PATCH", f"/messages/{message_id}", body={"body": body})
return self._wrap(data, Message)

async def list_message_edits(self, message_id: str) -> dict:
"""Walk the edit timeline for a message."""
return await self._raw_request("GET", f"/messages/{message_id}/edits")

async def delete_message(self, message_id: str) -> dict:
"""Soft-delete a message. Only the sender can delete their own."""
return await self._raw_request("DELETE", f"/messages/{message_id}")

async def toggle_star_message(self, message_id: str) -> dict:
"""Toggle whether the caller has starred (saved) a message."""
return await self._raw_request("POST", f"/messages/{message_id}/star")

async def list_saved_messages(self, limit: int = 50, offset: int = 0) -> dict:
"""List the caller's starred messages, newest-saved first."""
from urllib.parse import urlencode

params = urlencode({"limit": str(limit), "offset": str(offset)})
return await self._raw_request("GET", f"/messages/saved?{params}")

async def forward_message(
self,
message_id: str,
recipient_username: str,
comment: str = "",
) -> dict:
"""Forward a DM to another user as a new 1:1 message."""
from urllib.parse import urlencode

params = urlencode({"recipient_username": recipient_username, "comment": comment})
data = await self._raw_request("POST", f"/messages/{message_id}/forward?{params}")
return self._wrap(data, Message)

# ── Attachments + group avatar (multipart) ───────────────────────

async def upload_message_attachment(
self,
filename: str,
file_bytes: bytes,
content_type: str,
) -> dict:
"""Upload an image for use as a DM attachment."""
return await self._raw_multipart_upload(
"/messages/attachments/upload",
field_name="file",
filename=filename,
file_bytes=file_bytes,
content_type=content_type,
)

async def delete_message_attachment(self, attachment_id: str) -> None:
"""Soft-delete an attachment the caller uploaded."""
await self._raw_request("DELETE", f"/messages/attachments/{attachment_id}")

async def get_message_attachment(self, attachment_id: str, variant: str = "full") -> bytes:
"""Fetch the raw bytes of an attachment variant."""
return await self._raw_request_bytes(f"/messages/attachments/{attachment_id}/{variant}")

async def upload_group_avatar(
self,
conv_id: str,
filename: str,
file_bytes: bytes,
content_type: str,
) -> dict:
"""Upload a square avatar for a group. Admins only."""
return await self._raw_multipart_upload(
f"/messages/groups/{conv_id}/avatar",
field_name="file",
filename=filename,
file_bytes=file_bytes,
content_type=content_type,
)

async def get_group_avatar(self, conv_id: str) -> bytes:
"""Stream the group avatar bytes. Caller must be a member."""
return await self._raw_request_bytes(f"/messages/groups/{conv_id}/avatar")

# ── Multipart upload + binary GET (async) ────────────────────────
#
# See the sync ColonyClient counterparts for the wire-format
# rationale. httpx supports native ``files=`` on multipart POST,
# so we let it build the envelope rather than hand-rolling one.

async def _raw_multipart_upload(
self,
path: str,
*,
field_name: str,
filename: str,
file_bytes: bytes,
content_type: str,
) -> dict:
"""Async multipart POST, returning the JSON envelope."""
from colony_sdk import __version__

if self._token is None:
await self._ensure_token()

url = f"{self.base_url}{path}"
headers = {
"User-Agent": f"colony-sdk-python/{__version__}",
"Authorization": f"Bearer {self._token}",
}
files = {field_name: (filename, file_bytes, content_type)}

for hook in self._on_request:
hook("POST", url, None)

try:
resp = await self._get_client().post(url, headers=headers, files=files)
except httpx.HTTPError as e:
raise ColonyNetworkError(
f"Colony API network error (POST {path}): {e}",
status=0,
response={},
) from e

if resp.status_code >= 400:
retry_after = resp.headers.get("Retry-After") if resp.status_code == 429 else None
raise _build_api_error(
status=resp.status_code,
raw_body=resp.text,
fallback=f"Upload failed ({resp.status_code})",
message_prefix=f"Colony API error (POST {path})",
retry_after=int(retry_after) if retry_after else None,
)

data = resp.json() if resp.content else {}
for hook in self._on_response:
hook("POST", url, resp.status_code, data)
return data # type: ignore[no-any-return]

async def _raw_request_bytes(self, path: str) -> bytes:
"""Async GET returning the raw response body as bytes."""
from colony_sdk import __version__

if self._token is None:
await self._ensure_token()

url = f"{self.base_url}{path}"
headers = {
"User-Agent": f"colony-sdk-python/{__version__}",
"Authorization": f"Bearer {self._token}",
}

for hook in self._on_request:
hook("GET", url, None)

try:
resp = await self._get_client().get(url, headers=headers)
except httpx.HTTPError as e:
raise ColonyNetworkError(
f"Colony API network error (GET {path}): {e}",
status=0,
response={},
) from e

if resp.status_code >= 400:
raise _build_api_error(
status=resp.status_code,
raw_body=resp.text,
fallback=f"Download failed ({resp.status_code})",
message_prefix=f"Colony API error (GET {path})",
)

for hook in self._on_response:
hook("GET", url, resp.status_code, None)
return resp.content

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

async def search(
Expand Down
Loading