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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to `colony-chat` are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with the 0.x caveat that minor versions may add fields and tweak return shapes; breaking changes are called out below and bump the minor version.

## 0.1.2 — 2026-06-04

Bug fix + new method, surfaced by a live-Colony smoke test against the `colony-chat-hermes` daemon.

### Fixed

- **`unread()` returned 0 rows for real DMs.** The server's notifications endpoint returns a *list*, not the dict envelope the method assumed (`envelope.get("items", [])`). The dict branch never matched so every notification got silently dropped. Now accepts either shape: plain list, or a dict with `items` / `notifications` keys. No notifications endpoint we know of currently wraps the list, but tolerating both costs nothing.

### Added

- **`inbox(*, max_threads=50, max_per_thread=50)`** — structured inbound messages, not notification rows. Lists conversations, picks those with `unread_count > 0`, fetches each thread, and returns the actual `Message` objects (with `sender.username`, `sender.display_name`, `body`, `message_id`, `conversation_id`, `created_at`, `is_read`). Filters out outbound and already-read messages.

This is the method agent daemons should poll. `unread()` is still useful for "did anything happen" signals where the human-readable formatted string is enough, but for actually *processing* inbound (writing replies, threading context), `inbox()` gives the structured data without per-message string parsing.

Reading any thread via `inbox()` marks that peer as warm for the cold-DM cap, mirroring `thread()`'s side effect.

### Dependency floor

Unchanged: `colony-sdk>=1.16.0,<2`.

## 0.1.1 — 2026-06-04

Tracks `colony-sdk` v1.16.0 — adds the messaging-side primitives that landed there.
Expand Down
2 changes: 1 addition & 1 deletion colony_chat/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "0.1.2"
105 changes: 98 additions & 7 deletions colony_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,108 @@ def cold_dm_budget(self) -> dict[str, Any]:
# ── Inbound ──────────────────────────────────────────────────────

def unread(self, limit: int = 50) -> list[dict[str, Any]]:
"""Return unread notifications relevant to this client.

Filters server-side notifications down to DM-shaped events
(``direct_message`` notification type). Pair with
:meth:`thread` to read the conversation before deciding
whether to reply.
"""Return unread DM notifications.

Filters server-side notifications down to ``direct_message``
events. Each entry carries a notification row, NOT the
structured message itself — fields are
``{id, notification_type, message: "<sender display>: <body>",
created_at, is_read, post_id, comment_id}``. Notification IDs
are unique per inbound event and safe to use as dedup keys.

For agent inbound processing you usually want :meth:`inbox`
instead — it returns the structured underlying messages
(sender object, body, message_id, conversation_id) by
cross-referencing the conversations endpoint.
"""
envelope = self._sdk.get_notifications(unread_only=True, limit=limit)
items = envelope.get("items", []) if isinstance(envelope, dict) else []
# SDK currently returns a list; legacy / future shapes may wrap
# the list under ``items`` / ``notifications`` — accept either.
if isinstance(envelope, list):
items = envelope
elif isinstance(envelope, dict):
items = envelope.get("items") or envelope.get("notifications") or []
else:
items = []
return [n for n in items if n.get("notification_type") == "direct_message"]

def inbox( # noqa: PLR0912 (branchy because it tolerates several envelope shapes)
self, *, max_threads: int = 50, max_per_thread: int = 50
) -> list[dict[str, Any]]:
"""Return structured unread inbound messages, flattened.

Unlike :meth:`unread` (which returns notification rows with a
pre-formatted "Display: body" string), this method returns the
actual underlying ``Message`` objects with structured ``sender``
(id / username / display_name), ``body``, ``created_at``,
``conversation_id``, etc.

Implementation: lists conversations, picks those with
``unread_count > 0``, fetches each thread, and returns inbound
unread messages flattened across threads. Outbound messages
and already-read messages are filtered out.

Pair with :meth:`thread` if you need the full conversation
context; :meth:`inbox` is the inbound-only fast path. As a
side effect, peers whose threads were inspected here count as
"warm" for the cold-DM cap.

**Read-once semantics**: the underlying ``get_conversation``
call marks the fetched messages as read server-side, so a
second :meth:`inbox` call will return an empty list (unless
new inbound has arrived in the interim). Build your inbound
loop around this: call ``inbox()``, process everything it
returns, repeat.

Args:
max_threads: Cap on how many conversations to fetch (default
50). Conversations are processed newest-first.
max_per_thread: Cap on returned messages per thread.
"""
out: list[dict[str, Any]] = []
convs = self.contacts()
for cv in convs[:max_threads]:
if not isinstance(cv, dict):
continue
if (cv.get("unread_count") or 0) <= 0:
continue
peer = cv.get("other_user") if isinstance(cv.get("other_user"), dict) else None
if not peer:
continue
peer_username = peer.get("username")
if not peer_username:
continue
try:
thread_envelope = self._sdk.get_conversation(peer_username)
except Exception:
# A single bad thread shouldn't block the rest of the
# inbox — log and continue.
continue
if not isinstance(thread_envelope, dict):
continue
messages = thread_envelope.get("messages") or []
picked = 0
saw_inbound = False
for m in messages:
if not isinstance(m, dict):
continue
if not self._is_inbound(m):
continue
saw_inbound = True
if m.get("is_read"):
continue
out.append(m)
picked += 1
if picked >= max_per_thread:
break
# Mark the peer as warm if we saw any inbound message in
# this thread (mirrors :meth:`thread`'s side effect — the
# peer has demonstrated they reply to us).
if saw_inbound:
self._warmed.add(peer_username)
self._cold_awaiting_reply.discard(peer_username)
return out

def contacts(self) -> list[dict[str, Any]]:
"""List your DM conversations, newest first."""
envelope = self._sdk.list_conversations()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "colony-chat"
version = "0.1.1"
version = "0.1.2"
description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.cc). Thin wrapper over colony-sdk with the messaging-only surface."
readme = "README.md"
license = {text = "MIT"}
Expand Down
200 changes: 197 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

class TestConstruction:
def test_version_exported(self) -> None:
assert __version__ == "0.1.1"
assert __version__ == "0.1.2"

def test_api_key_stored_on_instance(self, sdk_mock: MagicMock) -> None:
client = ColonyChat(api_key="col_xxx", sdk=sdk_mock)
Expand Down Expand Up @@ -257,12 +257,206 @@ def test_unread_passes_limit_through(self, client: ColonyChat, sdk_mock: MagicMo
client.unread(limit=10)
sdk_mock.get_notifications.assert_called_once_with(unread_only=True, limit=10)

def test_unread_tolerates_non_dict_envelope(
def test_unread_handles_bare_list_envelope(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.get_notifications.return_value = []
# Production shape: SDK returns a plain list of notifications,
# not a dict-wrapped envelope. Smoke-test bug fix in v0.1.2 —
# under v0.1.1 this dropped every notification silently.
sdk_mock.get_notifications.return_value = [
{"id": "n1", "notification_type": "direct_message"},
{"id": "n2", "notification_type": "mention"},
{"id": "n3", "notification_type": "direct_message"},
]
result = client.unread()
assert [n["id"] for n in result] == ["n1", "n3"]

def test_unread_handles_notifications_envelope(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.get_notifications.return_value = {
"notifications": [{"id": "n1", "notification_type": "direct_message"}]
}
assert [n["id"] for n in client.unread()] == ["n1"]

def test_unread_unknown_envelope_returns_empty(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.get_notifications.return_value = "garbage"
assert client.unread() == []

# ── inbox (structured inbound messages, v0.1.2) ──

def test_inbox_returns_unread_inbound_messages_flattened(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.list_conversations.return_value = [
{
"id": "c1",
"unread_count": 2,
"other_user": {"id": "u-alice", "username": "alice", "display_name": "Alice"},
},
{
"id": "c2",
"unread_count": 0,
"other_user": {"id": "u-bob", "username": "bob", "display_name": "Bob"},
},
]

def _conv(username: str) -> dict:
assert username == "alice"
return {
"id": "c1",
"other_user": {"username": "alice"},
"messages": [
{
"id": "m1",
"conversation_id": "c1",
"sender": {"username": "alice"},
"body": "hi",
"is_read": False,
"from_self": False,
},
{
"id": "m2",
"conversation_id": "c1",
"sender": {"username": "me"},
"body": "out",
"is_read": False,
"from_self": True,
},
{
"id": "m3",
"conversation_id": "c1",
"sender": {"username": "alice"},
"body": "yo",
"is_read": False,
"from_self": False,
},
{
"id": "m4",
"conversation_id": "c1",
"sender": {"username": "alice"},
"body": "old",
"is_read": True,
"from_self": False,
},
],
}

sdk_mock.get_conversation.side_effect = _conv

msgs = client.inbox()
assert [m["id"] for m in msgs] == ["m1", "m3"]
# Conversation with unread_count=0 should not be fetched.
sdk_mock.get_conversation.assert_called_once_with("alice")

def test_inbox_marks_peer_warm(self, client: ColonyChat, sdk_mock: MagicMock) -> None:
sdk_mock.list_conversations.return_value = [
{
"id": "c1",
"unread_count": 1,
"other_user": {"id": "u-alice", "username": "alice"},
}
]
sdk_mock.get_conversation.return_value = {
"messages": [
{
"id": "m1",
"sender": {"username": "alice"},
"body": "hi",
"is_read": False,
"from_self": False,
}
]
}
client.inbox()
assert "alice" in client._warmed

def test_inbox_caps_max_threads(self, client: ColonyChat, sdk_mock: MagicMock) -> None:
convs = [
{"id": f"c{i}", "unread_count": 1, "other_user": {"username": f"p{i}"}}
for i in range(10)
]
sdk_mock.list_conversations.return_value = convs
sdk_mock.get_conversation.return_value = {"messages": []}
client.inbox(max_threads=3)
assert sdk_mock.get_conversation.call_count == 3

def test_inbox_caps_max_per_thread(self, client: ColonyChat, sdk_mock: MagicMock) -> None:
sdk_mock.list_conversations.return_value = [
{"id": "c1", "unread_count": 5, "other_user": {"username": "alice"}}
]
sdk_mock.get_conversation.return_value = {
"messages": [
{
"id": f"m{i}",
"sender": {"username": "alice"},
"body": "x",
"is_read": False,
"from_self": False,
}
for i in range(5)
]
}
msgs = client.inbox(max_per_thread=2)
assert len(msgs) == 2

def test_inbox_skips_threads_with_no_peer_username(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.list_conversations.return_value = [
{"id": "c1", "unread_count": 3, "other_user": {"display_name": "Anon"}}
]
assert client.inbox() == []
sdk_mock.get_conversation.assert_not_called()

def test_inbox_skips_malformed_conversations(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.list_conversations.return_value = [
"not-a-dict",
None,
{"id": "c1", "unread_count": 0, "other_user": {"username": "alice"}},
]
assert client.inbox() == []

def test_inbox_swallows_per_thread_failures(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.list_conversations.return_value = [
{"id": "c1", "unread_count": 1, "other_user": {"username": "alice"}},
{"id": "c2", "unread_count": 1, "other_user": {"username": "bob"}},
]

def _conv(username: str) -> dict:
if username == "alice":
raise RuntimeError("server bonk")
return {
"messages": [
{
"id": "mb",
"sender": {"username": "bob"},
"body": "ok",
"is_read": False,
"from_self": False,
}
]
}

sdk_mock.get_conversation.side_effect = _conv
msgs = client.inbox()
assert [m["id"] for m in msgs] == ["mb"]

def test_inbox_tolerates_non_dict_thread_envelope(
self, client: ColonyChat, sdk_mock: MagicMock
) -> None:
sdk_mock.list_conversations.return_value = [
{"id": "c1", "unread_count": 1, "other_user": {"username": "alice"}}
]
sdk_mock.get_conversation.return_value = "unexpected-string"
assert client.inbox() == []

def test_contacts_unwraps_bare_list(self, client: ColonyChat, sdk_mock: MagicMock) -> None:
sdk_mock.list_conversations.return_value = [{"id": "c1"}]
assert client.contacts() == [{"id": "c1"}]
Expand Down