diff --git a/apps/chat/integrations/mattermost/client.py b/apps/chat/integrations/mattermost/client.py index 659a5ed..bafb5ed 100644 --- a/apps/chat/integrations/mattermost/client.py +++ b/apps/chat/integrations/mattermost/client.py @@ -156,6 +156,15 @@ class MattermostClient: return channel_id + @staticmethod + def _is_system_post(post: dict) -> bool: + """ + Mattermost auto-generates posts like "user-x added to the channel" + (type e.g. "system_add_to_channel") whenever channel membership + changes. Real user-sent posts have an empty type. + """ + return post.get("type", "").startswith("system_") + def post_message(self, channel_id: str, message: str) -> str: """ Post a message to the given channel using the admin token. @@ -181,6 +190,8 @@ class MattermostClient: ) -> list[dict]: """ Fetch posts from a channel and return them in chronological order. + System posts (channel membership changes, etc.) are excluded — only + real user-sent posts are returned. `since` is a Unix timestamp in **milliseconds**. When provided, `page` is ignored and all posts after that timestamp are returned. @@ -200,19 +211,30 @@ class MattermostClient: posts_map: dict = data.get("posts", {}) # Mattermost returns newest-first; reverse to get chronological order. - return [posts_map[pid] for pid in reversed(order) if pid in posts_map] + return [ + posts_map[pid] + for pid in reversed(order) + if pid in posts_map and not self._is_system_post(posts_map[pid]) + ] def get_latest_post_id(self, channel_id: str) -> str | None: """ - Return the ID of the most recent post in the channel, or None if empty. + Return the ID of the most recent real (non-system) post in the + channel, or None if there are none yet. Used to derive `has_unread` against a stored last_read_mattermost_post_id. """ try: data = self._driver.posts.get_posts_for_channel( - channel_id, params={"page": 0, "per_page": 1} + channel_id, params={"page": 0, "per_page": 20} ) except Exception as exc: raise MattermostError(f"Fetching latest post failed: {exc}") from exc order: list[str] = data.get("order", []) - return order[0] if order else None + posts_map: dict = data.get("posts", {}) + + for pid in order: # newest-first + post = posts_map.get(pid) + if post and not self._is_system_post(post): + return pid + return None diff --git a/apps/chat/tests/test_mattermost_client.py b/apps/chat/tests/test_mattermost_client.py new file mode 100644 index 0000000..91a1924 --- /dev/null +++ b/apps/chat/tests/test_mattermost_client.py @@ -0,0 +1,77 @@ +from unittest.mock import Mock + +from apps.chat.integrations.mattermost.client import MattermostClient + + +def _make_client(driver_response: dict) -> MattermostClient: + """Build a MattermostClient without hitting the network (no __init__).""" + client = MattermostClient.__new__(MattermostClient) + client._driver = Mock() + client._driver.posts.get_posts_for_channel.return_value = driver_response + return client + + +def _post(post_id, msg="hi", post_type="", user_id="u1"): + return { + "id": post_id, + "type": post_type, + "message": msg, + "user_id": user_id, + "create_at": 1700000000000, + } + + +def test_get_posts_excludes_system_posts(): + # Mattermost order is newest-first. + posts_map = { + "p1": _post("p1", "real message"), + "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), + } + client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) + + result = client.get_posts("chan-1") + + assert [p["id"] for p in result] == ["p1"] + + +def test_get_posts_keeps_chronological_order_with_no_system_posts(): + posts_map = { + "p1": _post("p1", "first"), + "p2": _post("p2", "second"), + } + client = _make_client({"order": ["p2", "p1"], "posts": posts_map}) + + result = client.get_posts("chan-1") + + assert [p["id"] for p in result] == ["p1", "p2"] + + +def test_get_latest_post_id_skips_leading_system_post(): + # sys1 is newest (e.g. someone was just added), p1 is the latest real message. + posts_map = { + "sys1": _post("sys1", "user-y added to the channel", post_type="system_add_to_channel"), + "p1": _post("p1", "real message"), + } + client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") == "p1" + + +def test_get_latest_post_id_returns_none_when_only_system_posts_exist(): + posts_map = { + "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), + "sys2": _post("sys2", "user-y added to the channel", post_type="system_add_to_channel"), + } + client = _make_client({"order": ["sys2", "sys1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") is None + + +def test_get_latest_post_id_returns_newest_real_post_when_no_system_posts(): + posts_map = { + "p1": _post("p1", "first"), + "p2": _post("p2", "second"), + } + client = _make_client({"order": ["p2", "p1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") == "p2"