diff --git a/CHANGELOG.md b/CHANGELOG.md index 221752f..875a64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/colony_chat/_version.py b/colony_chat/_version.py index 485f44a..b3f4756 100644 --- a/colony_chat/_version.py +++ b/colony_chat/_version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/colony_chat/client.py b/colony_chat/client.py index 77c201c..58ce759 100644 --- a/colony_chat/client.py +++ b/colony_chat/client.py @@ -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: ": ", + 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() diff --git a/pyproject.toml b/pyproject.toml index c63556c..c82760f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/tests/test_client.py b/tests/test_client.py index 1395873..2a0d505 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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) @@ -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"}]