Explorar el Código

Update for Chatto v0.1.0-beta.3 schema

Major API surface changes driven by the renamed/restructured server schema:
- Subscription events: MessageUpdatedEvent → MessageEditedEvent,
  MessageDeletedEvent → MessageRetractedEvent, VideoProcessingCompletedEvent
  → AssetProcessing{Started,Succeeded,Failed,Deleted}Event,
  RoomLayoutUpdatedEvent → RoomGroupsUpdatedEvent; new ThreadCreatedEvent,
  RoomMemberBanned/UnbannedEvent, MentionStatusClearedEvent.
- Server profile fields moved under server.profile sub-object;
  viewer.notifications and viewer.followedThreads are now connection-shaped.
- Messages use threadRootEventId (inThread is gone); Reaction renamed to
  ReactionSummary (kept as alias).
- create_room now requires group_id; update_profile requires user_id;
  update_presence uses PresenceStatusInput (OFFLINE rejected).
- Adds ~25 new client methods: room groups, room member bans, server
  logo/banner uploads, push subscriptions, notification level controls,
  account deletion, settings, link preview lookup, etc.

Bumps version 0.0.174a1 → 0.1.0b3.
TheCodemancer hace 4 meses
padre
commit
b33b485415

+ 16 - 9
CLAUDE.md

@@ -2,6 +2,8 @@
 
 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 
+**IMPORTANT**: before you do anything else, run the `beans prime` command and heed its output. Tasks for this project are tracked in `.beans/` via the [beans](https://github.com/hmans/beans) CLI.
+
 ## Project Overview
 
 **chattolib** is an async Python client library for the [Chatto](https://chat.chatto.run) webchat GraphQL API (`https://chat.chatto.run/api/graphql`). It wraps the full Chatto GraphQL schema — queries, mutations, and subscriptions — into a typed, Pythonic async interface.
@@ -42,17 +44,20 @@ The library uses **httpx** for async HTTP and **websockets** for GraphQL subscri
 
 | Domain | Queries | Mutations | Subscriptions |
 |---|---|---|---|
-| **Server** | `server` | `updateServer`, logo/banner uploads | `myEvents` |
-| **Rooms** | `room`, `room.events`, `room.event`, `room.eventsAround` | `createRoom`, `updateRoom`, `archiveRoom`, `joinRoom`, `leaveRoom`, `markRoomAsRead` | `myEvents` |
-| **Messages** | (via room.events) | `postMessage`, `editMessage`, `deleteMessage` | `MessagePostedEvent`, `MessageUpdatedEvent`, `MessageDeletedEvent` |
+| **Server** | `server`, `server.profile` | `updateServerConfig`, `uploadServerLogo`, `deleteServerLogo`, `uploadServerBanner`, `deleteServerBanner` | `ServerUpdatedEvent`, `RoomGroupsUpdatedEvent` |
+| **Rooms** | `room`, `room.events`, `room.event`, `room.eventsAround` | `createRoom`, `updateRoom`, `archiveRoom`, `unarchiveRoom`, `joinRoom`, `leaveRoom`, `banRoomMember`, `unbanRoomMember`, `markRoomAsRead` | `myEvents` |
+| **Room groups** | `server.roomGroups` | `createRoomGroup`, `updateRoomGroup`, `deleteRoomGroup`, `reorderRoomGroups`, `moveRoomToGroup`, `reorderRoomsInGroup`, `joinGroup` | `RoomGroupsUpdatedEvent` |
+| **Messages** | (via room.events) | `postMessage`, `updateMessage`, `deleteMessage`, `deleteAttachment`, `deleteLinkPreview` | `MessagePostedEvent`, `MessageEditedEvent`, `MessageRetractedEvent` |
+| **Assets** | (via attachment IDs) | (server-driven) | `AssetProcessingStartedEvent`, `AssetProcessingSucceededEvent`, `AssetProcessingFailedEvent`, `AssetDeletedEvent` |
 | **Reactions** | (on message events) | `addReaction`, `removeReaction` | `ReactionAddedEvent`, `ReactionRemovedEvent` |
-| **Threads** | `room.event.threadReplies`, `viewer.followedThreads` | `followThread`, `unfollowThread`, `markThreadAsRead` | `ThreadFollowChangedEvent` |
-| **Users** | `viewer.user`, `user(id)`, `userByLogin`, `users` | `updateProfile`, `uploadAvatar`, `deleteAvatar` | `UserProfileUpdatedEvent`, `PresenceChangedEvent` |
+| **Threads** | `room.event.threadReplies` (via `MessagePostedEvent`), `viewer.followedThreads` | `followThread`, `unfollowThread`, `markThreadAsRead` | `ThreadCreatedEvent`, `ThreadFollowChangedEvent` |
+| **Users** | `viewer.user`, `user(id)`, `userByLogin`, `server.members` | `updateProfile`, `uploadAvatar`, `deleteAvatar`, `updateSettings`, `requestAccountDeletion`, `deleteMyAccount` | `UserCreatedEvent`, `UserDeletedEvent`, `UserProfileUpdatedEvent`, `PresenceChangedEvent` |
 | **DMs** | (via rooms) | `startDM` | `NewDirectMessageNotificationEvent` |
-| **Notifications** | `viewer.notifications`, `viewer.hasNotifications` | `dismissNotification`, `dismissAllNotifications` | `NotificationCreatedEvent`, `NotificationDismissedEvent` |
-| **Permissions/Roles** | via `admin` query, `server.roles` | `grantPermission`, `createRole`, `assignRole` | — |
+| **Notifications** | `viewer.notifications` (connection), `viewer.hasNotifications` | `dismissNotification`, `dismissAllNotifications`, `setServerNotificationLevel`, `setRoomNotificationLevel` | `NotificationCreatedEvent`, `NotificationDismissedEvent`, `NotificationLevelChangedEvent`, `MentionStatusClearedEvent` |
+| **Push** | — | `subscribeToPush`, `unsubscribeFromPush` | — |
+| **Permissions/Roles** | `admin.rbac`, `server.roles` | `grantPermission`, `revokePermission`, `createRole`, `updateRole`, `deleteRole`, `assignRole`, plus room/user/group variants | — |
 | **Voice calls** | `room.voiceCallToken`, `activeCallRoomIds`, `room.callParticipants` | — | `CallParticipantJoinedEvent`, `CallParticipantLeftEvent` |
-| **Admin** | `admin.systemInfo`, `admin.serverConfig`, `admin.roles` | `admin.updateServerConfig`, `admin.resetServerConfig` | via `myEvents` |
+| **Admin** | `admin.systemInfo`, `admin.serverConfig`, `admin.eventLog`, `admin.roomBans`, `admin.projections` | `admin.updateUser`, `admin.updateBlockedUsernames`, `admin.clearUsernameCooldown` | via `myEvents` |
 
 ### GraphQL conventions
 
@@ -62,7 +67,9 @@ The library uses **httpx** for async HTTP and **websockets** for GraphQL subscri
 - Pagination uses `limit`/`before`/`after` on room events (`before`/`after` are `Time` scalars, ISO timestamps).
 - All mutations take a single `input` argument with a corresponding `*Input` type.
 - Single unified subscription: `myEvents` for all server, room, and instance events.
-- Rooms have a `type` field: `CHANNEL` or `DM`.
+- Rooms have a `type` field: `CHANNEL` or `DM`. Channels live inside a `RoomGroup` (`groupId` is required at creation).
+- Threads are addressed by `threadRootEventId` (no separate `inThread` field on messages).
+- Presence: `PresenceStatus` (OFFLINE/ONLINE/AWAY/DO_NOT_DISTURB) is the observable type; `PresenceStatusInput` (no OFFLINE) is what callers can set via `updateMyPresence`.
 - Image URLs accept optional `width`, `height`, `fit` (enum: `CONTAIN`, `COVER`, `EXACT`) for server-side resizing.
 
 ### Naming conventions

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.0.174a1"
+version = "0.1.0b3"
 description = "Async Python client library for the Chatto webchat GraphQL API"
 requires-python = ">=3.11"
 license = "MIT"

+ 41 - 1
src/chattolib/__init__.py

@@ -1,11 +1,51 @@
 """Async Python client library for the Chatto webchat GraphQL API."""
 
 from chattolib.client import ChattoClient
-from chattolib.exceptions import ChattoError, ChattoGraphQLError, ChattoAuthError
+from chattolib.exceptions import ChattoAuthError, ChattoError, ChattoGraphQLError
+from chattolib.types import (
+    Attachment,
+    FollowedThread,
+    FollowedThreadsPage,
+    LinkPreview,
+    MessageEvent,
+    NotificationLevel,
+    NotificationsPage,
+    PresenceStatus,
+    PresenceStatusInput,
+    Reaction,
+    ReactionSummary,
+    Room,
+    RoomEventsPage,
+    RoomGroup,
+    RoomType,
+    ServerProfile,
+    TimeFormat,
+    User,
+    UserSettings,
+)
 
 __all__ = [
     "ChattoClient",
     "ChattoError",
     "ChattoGraphQLError",
     "ChattoAuthError",
+    "Attachment",
+    "FollowedThread",
+    "FollowedThreadsPage",
+    "LinkPreview",
+    "MessageEvent",
+    "NotificationLevel",
+    "NotificationsPage",
+    "PresenceStatus",
+    "PresenceStatusInput",
+    "Reaction",
+    "ReactionSummary",
+    "Room",
+    "RoomEventsPage",
+    "RoomGroup",
+    "RoomType",
+    "ServerProfile",
+    "TimeFormat",
+    "User",
+    "UserSettings",
 ]

+ 401 - 89
src/chattolib/client.py

@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 from datetime import datetime
-from typing import Any, AsyncIterator
+from typing import Any
 
 import httpx
 
@@ -12,14 +12,22 @@ from chattolib.exceptions import ChattoAuthError, ChattoGraphQLError
 from chattolib.types import (
     Attachment,
     FollowedThread,
+    FollowedThreadsPage,
     LinkPreview,
     MessageEvent,
+    NotificationLevel,
+    NotificationsPage,
     PresenceStatus,
-    Reaction,
+    PresenceStatusInput,
+    ReactionSummary,
     Room,
     RoomEventsPage,
+    RoomGroup,
     RoomType,
+    ServerProfile,
+    TimeFormat,
     User,
+    UserSettings,
 )
 
 
@@ -29,14 +37,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
     return datetime.fromisoformat(value)
 
 
+def _parse_user_settings(data: dict[str, Any] | None) -> UserSettings | None:
+    if data is None:
+        return None
+    return UserSettings(
+        timezone=data.get("timezone"),
+        time_format=TimeFormat(data["timeFormat"])
+        if data.get("timeFormat")
+        else TimeFormat.UNSPECIFIED,
+    )
+
+
 def _parse_user(data: dict[str, Any]) -> User:
+    status_raw = data.get("presenceStatus")
     return User(
         id=data["id"],
         login=data["login"],
         display_name=data["displayName"],
+        presence_status=PresenceStatus(status_raw) if status_raw else PresenceStatus.OFFLINE,
         created_at=_parse_datetime(data.get("createdAt")),
         avatar_url=data.get("avatarUrl"),
-        presence_status=PresenceStatus(data["presenceStatus"]) if data.get("presenceStatus") else None,
+        settings=_parse_user_settings(data.get("settings")),
     )
 
 
@@ -49,7 +70,26 @@ def _parse_room(data: dict[str, Any]) -> Room:
         archived=data.get("archived", False),
         group_id=data.get("groupId"),
         has_unread=data.get("hasUnread", False),
-        has_mention=data.get("hasMention", False),
+    )
+
+
+def _parse_room_group(data: dict[str, Any]) -> RoomGroup:
+    return RoomGroup(
+        id=data["id"],
+        name=data["name"],
+        description=data.get("description", ""),
+        room_ids=[r["id"] for r in data.get("rooms", [])],
+    )
+
+
+def _parse_server_profile(data: dict[str, Any]) -> ServerProfile:
+    return ServerProfile(
+        name=data["name"],
+        logo_url=data.get("logoUrl"),
+        banner_url=data.get("bannerUrl"),
+        welcome_message=data.get("welcomeMessage"),
+        motd=data.get("motd"),
+        description=data.get("description"),
     )
 
 
@@ -60,14 +100,15 @@ def _parse_attachment(data: dict[str, Any]) -> Attachment:
         filename=data["filename"],
         content_type=data["contentType"],
         size=data["size"],
-        url=data.get("url"),
-        width=data.get("width"),
-        height=data.get("height"),
+        width=data.get("width", 0),
+        height=data.get("height", 0),
+        url=data.get("url", ""),
+        thumbnail_url=data.get("thumbnailUrl"),
     )
 
 
-def _parse_reaction(data: dict[str, Any]) -> Reaction:
-    return Reaction(
+def _parse_reaction(data: dict[str, Any]) -> ReactionSummary:
+    return ReactionSummary(
         emoji=data["emoji"],
         count=data["count"],
         has_reacted=data.get("hasReacted", False),
@@ -75,33 +116,41 @@ def _parse_reaction(data: dict[str, Any]) -> Reaction:
     )
 
 
+def _parse_link_preview(data: dict[str, Any]) -> LinkPreview:
+    return LinkPreview(
+        url=data["url"],
+        title=data.get("title"),
+        description=data.get("description"),
+        image_url=data.get("imageUrl"),
+        image_asset_id=data.get("imageAssetId"),
+        site_name=data.get("siteName"),
+        embed_type=data.get("embedType"),
+        embed_id=data.get("embedId"),
+    )
+
+
 def _parse_message_event(data: dict[str, Any]) -> MessageEvent:
-    event = data.get("event", {})
+    event = data.get("event") or {}
     actor_data = data.get("actor")
     return MessageEvent(
         id=data["id"],
         room_id=event.get("roomId", ""),
         body=event.get("body"),
         created_at=_parse_datetime(data.get("createdAt")),
+        updated_at=_parse_datetime(event.get("updatedAt")),
         actor=_parse_user(actor_data) if actor_data else None,
         attachments=[_parse_attachment(a) for a in event.get("attachments", [])],
         reactions=[_parse_reaction(r) for r in event.get("reactions", [])],
         in_reply_to=event.get("inReplyTo"),
-        in_thread=event.get("inThread"),
+        thread_root_event_id=event.get("threadRootEventId"),
         reply_count=event.get("replyCount", 0),
-        link_preview=_parse_link_preview(event["linkPreview"]) if event.get("linkPreview") else None,
-    )
-
-
-def _parse_link_preview(data: dict[str, Any]) -> LinkPreview:
-    return LinkPreview(
-        url=data["url"],
-        title=data.get("title"),
-        description=data.get("description"),
-        image_url=data.get("imageUrl"),
-        site_name=data.get("siteName"),
-        embed_type=data.get("embedType"),
-        embed_id=data.get("embedId"),
+        last_reply_at=_parse_datetime(event.get("lastReplyAt")),
+        link_preview=_parse_link_preview(event["linkPreview"])
+        if event.get("linkPreview")
+        else None,
+        echo_of_event_id=event.get("echoOfEventId"),
+        echo_from_thread_root_event_id=event.get("echoFromThreadRootEventId"),
+        viewer_is_following_thread=event.get("viewerIsFollowingThread"),
     )
 
 
@@ -258,10 +307,18 @@ class ChattoClient:
         data = await self._execute(Q.QUERY_SERVER)
         return data["server"]
 
+    async def server_profile(self) -> ServerProfile:
+        data = await self._execute(Q.QUERY_SERVER)
+        return _parse_server_profile(data["server"]["profile"])
+
     async def rooms(self) -> list[Room]:
         data = await self._execute(Q.QUERY_SERVER)
         return [_parse_room(r) for r in data["server"]["rooms"]]
 
+    async def room_groups(self) -> list[RoomGroup]:
+        data = await self._execute(Q.QUERY_SERVER)
+        return [_parse_room_group(g) for g in data["server"]["roomGroups"]]
+
     async def room(self, room_id: str) -> Room:
         data = await self._execute(Q.QUERY_ROOM, {"roomId": room_id})
         return _parse_room(data["room"])
@@ -300,7 +357,10 @@ class ChattoClient:
             Q.QUERY_THREAD_EVENTS,
             {"roomId": room_id, "eventId": thread_root_event_id},
         )
-        return [_parse_message_event(e) for e in data["room"]["event"]["threadReplies"]]
+        root = data["room"]["event"] or {}
+        event = root.get("event") or {}
+        thread = event.get("threadReplies") or {}
+        return [_parse_message_event(e) for e in thread.get("events", [])]
 
     async def user(self, user_id: str) -> User:
         data = await self._execute(Q.QUERY_USER, {"userId": user_id})
@@ -310,48 +370,69 @@ class ChattoClient:
         data = await self._execute(Q.QUERY_USER_BY_LOGIN, {"login": login})
         return _parse_user(data["userByLogin"])
 
-    async def users(self) -> list[User]:
-        data = await self._execute(Q.QUERY_USERS)
-        return [_parse_user(u) for u in data["users"]]
+    async def server_members(self) -> list[User]:
+        data = await self._execute(Q.QUERY_SERVER_MEMBERS)
+        return [_parse_user(u) for u in data["server"]["members"]["users"]]
 
-    async def notifications(self) -> list[dict[str, Any]]:
+    async def notifications(self) -> NotificationsPage:
         data = await self._execute(Q.QUERY_NOTIFICATIONS)
-        return data["viewer"]["notifications"]
+        conn = data["viewer"]["notifications"]
+        return NotificationsPage(
+            items=conn["items"],
+            total_count=conn.get("totalCount", 0),
+            has_more=conn.get("hasMore", False),
+        )
 
-    async def followed_threads(self) -> list[FollowedThread]:
+    async def followed_threads(self) -> FollowedThreadsPage:
         data = await self._execute(Q.QUERY_FOLLOWED_THREADS)
-        return [
-            FollowedThread(
-                room_id=t["roomId"],
-                thread_root_event_id=t["threadRootEventId"],
-                reply_count=t.get("replyCount", 0),
-                last_reply_at=_parse_datetime(t.get("lastReplyAt")),
-                has_unread=t.get("hasUnread", False),
-            )
-            for t in data["viewer"]["followedThreads"]
-        ]
+        conn = data["viewer"]["followedThreads"]
+        return FollowedThreadsPage(
+            threads=[
+                FollowedThread(
+                    room_id=t["roomId"],
+                    thread_root_event_id=t["threadRootEventId"],
+                    reply_count=t.get("replyCount", 0),
+                    last_reply_at=_parse_datetime(t.get("lastReplyAt")),
+                    has_unread=t.get("hasUnread", False),
+                )
+                for t in conn["threads"]
+            ],
+            total_count=conn.get("totalCount", 0),
+            has_more=conn.get("hasMore", False),
+        )
 
-    # --- Mutations ---
+    async def link_preview(self, url: str) -> LinkPreview | None:
+        data = await self._execute(Q.QUERY_LINK_PREVIEW, {"url": url})
+        lp = data.get("linkPreview")
+        return _parse_link_preview(lp) if lp else None
+
+    async def active_call_room_ids(self) -> list[str]:
+        data = await self._execute(Q.QUERY_ACTIVE_CALL_ROOM_IDS)
+        return data["activeCallRoomIds"]
+
+    # --- Message mutations ---
 
     async def post_message(
         self,
         room_id: str,
-        body: str,
+        body: str | None = None,
         *,
-        in_thread: str | None = None,
+        thread_root_event_id: str | None = None,
         in_reply_to: str | None = None,
         also_send_to_channel: bool | None = None,
+        link_preview: dict[str, Any] | None = None,
     ) -> dict[str, Any]:
-        input_data: dict[str, Any] = {
-            "roomId": room_id,
-            "body": body,
-        }
-        if in_thread is not None:
-            input_data["inThread"] = in_thread
+        input_data: dict[str, Any] = {"roomId": room_id}
+        if body is not None:
+            input_data["body"] = body
+        if thread_root_event_id is not None:
+            input_data["threadRootEventId"] = thread_root_event_id
         if in_reply_to is not None:
             input_data["inReplyTo"] = in_reply_to
         if also_send_to_channel is not None:
             input_data["alsoSendToChannel"] = also_send_to_channel
+        if link_preview is not None:
+            input_data["linkPreview"] = link_preview
         data = await self._execute(Q.MUTATION_POST_MESSAGE, {"input": input_data})
         return data["postMessage"]
 
@@ -376,72 +457,144 @@ class ChattoClient:
         )
         return data["deleteMessage"]
 
-    async def add_reaction(
-        self, room_id: str, message_event_id: str, emoji: str
-    ) -> Any:
+    async def delete_attachment(self, room_id: str, event_id: str, attachment_id: str) -> Any:
+        data = await self._execute(
+            Q.MUTATION_DELETE_ATTACHMENT,
+            {
+                "input": {
+                    "roomId": room_id,
+                    "eventId": event_id,
+                    "attachmentId": attachment_id,
+                }
+            },
+        )
+        return data["deleteAttachment"]
+
+    async def delete_link_preview(self, room_id: str, event_id: str, url: str) -> Any:
+        data = await self._execute(
+            Q.MUTATION_DELETE_LINK_PREVIEW,
+            {"input": {"roomId": room_id, "eventId": event_id, "url": url}},
+        )
+        return data["deleteLinkPreview"]
+
+    # --- Reactions ---
+
+    async def add_reaction(self, room_id: str, message_event_id: str, emoji: str) -> Any:
         data = await self._execute(
             Q.MUTATION_ADD_REACTION,
             {"input": {"roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
         )
         return data["addReaction"]
 
-    async def remove_reaction(
-        self, room_id: str, message_event_id: str, emoji: str
-    ) -> Any:
+    async def remove_reaction(self, room_id: str, message_event_id: str, emoji: str) -> Any:
         data = await self._execute(
             Q.MUTATION_REMOVE_REACTION,
             {"input": {"roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
         )
         return data["removeReaction"]
 
+    # --- Rooms ---
+
     async def create_room(
-        self, name: str, description: str | None = None
+        self,
+        name: str,
+        group_id: str,
+        description: str | None = None,
     ) -> Room:
-        input_data: dict[str, Any] = {"name": name}
+        input_data: dict[str, Any] = {"name": name, "groupId": group_id}
         if description is not None:
             input_data["description"] = description
         data = await self._execute(Q.MUTATION_CREATE_ROOM, {"input": input_data})
         return _parse_room(data["createRoom"])
 
+    async def update_room(
+        self,
+        room_id: str,
+        name: str,
+        description: str | None = None,
+    ) -> Room:
+        input_data: dict[str, Any] = {"roomId": room_id, "name": name}
+        if description is not None:
+            input_data["description"] = description
+        data = await self._execute(Q.MUTATION_UPDATE_ROOM, {"input": input_data})
+        return _parse_room(data["updateRoom"])
+
+    async def archive_room(self, room_id: str) -> Any:
+        data = await self._execute(Q.MUTATION_ARCHIVE_ROOM, {"input": {"roomId": room_id}})
+        return data["archiveRoom"]
+
+    async def unarchive_room(self, room_id: str) -> Any:
+        data = await self._execute(Q.MUTATION_UNARCHIVE_ROOM, {"input": {"roomId": room_id}})
+        return data["unarchiveRoom"]
+
     async def join_room(self, room_id: str) -> dict[str, Any]:
-        data = await self._execute(
-            Q.MUTATION_JOIN_ROOM, {"input": {"roomId": room_id}}
-        )
+        data = await self._execute(Q.MUTATION_JOIN_ROOM, {"input": {"roomId": room_id}})
         return data["joinRoom"]
 
     async def leave_room(self, room_id: str) -> Any:
-        data = await self._execute(
-            Q.MUTATION_LEAVE_ROOM, {"input": {"roomId": room_id}}
-        )
+        data = await self._execute(Q.MUTATION_LEAVE_ROOM, {"input": {"roomId": room_id}})
         return data["leaveRoom"]
 
-    async def mark_room_as_read(self, room_id: str) -> dict[str, Any]:
+    async def join_group(self, group_id: str) -> Any:
+        data = await self._execute(Q.MUTATION_JOIN_GROUP, {"input": {"groupId": group_id}})
+        return data["joinGroup"]
+
+    async def ban_room_member(
+        self,
+        room_id: str,
+        user_id: str,
+        reason: str,
+        expires_at: str | None = None,
+    ) -> Any:
+        input_data: dict[str, Any] = {
+            "roomId": room_id,
+            "userId": user_id,
+            "reason": reason,
+        }
+        if expires_at is not None:
+            input_data["expiresAt"] = expires_at
+        data = await self._execute(Q.MUTATION_BAN_ROOM_MEMBER, {"input": input_data})
+        return data["banRoomMember"]
+
+    async def unban_room_member(self, room_id: str, user_id: str, reason: str) -> Any:
         data = await self._execute(
-            Q.MUTATION_MARK_ROOM_AS_READ, {"input": {"roomId": room_id}}
+            Q.MUTATION_UNBAN_ROOM_MEMBER,
+            {"input": {"roomId": room_id, "userId": user_id, "reason": reason}},
         )
+        return data["unbanRoomMember"]
+
+    async def mark_room_as_read(
+        self, room_id: str, up_to_event_id: str | None = None
+    ) -> dict[str, Any]:
+        input_data: dict[str, Any] = {"roomId": room_id}
+        if up_to_event_id is not None:
+            input_data["upToEventId"] = up_to_event_id
+        data = await self._execute(Q.MUTATION_MARK_ROOM_AS_READ, {"input": input_data})
         return data["markRoomAsRead"]
 
     async def mark_thread_as_read(
-        self, room_id: str, thread_root_event_id: str
+        self,
+        room_id: str,
+        thread_root_event_id: str,
+        up_to_event_id: str | None = None,
     ) -> dict[str, Any]:
-        data = await self._execute(
-            Q.MUTATION_MARK_THREAD_AS_READ,
-            {"input": {"roomId": room_id, "threadRootEventId": thread_root_event_id}},
-        )
+        input_data: dict[str, Any] = {
+            "roomId": room_id,
+            "threadRootEventId": thread_root_event_id,
+        }
+        if up_to_event_id is not None:
+            input_data["upToEventId"] = up_to_event_id
+        data = await self._execute(Q.MUTATION_MARK_THREAD_AS_READ, {"input": input_data})
         return data["markThreadAsRead"]
 
-    async def follow_thread(
-        self, room_id: str, thread_root_event_id: str
-    ) -> Any:
+    async def follow_thread(self, room_id: str, thread_root_event_id: str) -> Any:
         data = await self._execute(
             Q.MUTATION_FOLLOW_THREAD,
             {"input": {"roomId": room_id, "threadRootEventId": thread_root_event_id}},
         )
         return data["followThread"]
 
-    async def unfollow_thread(
-        self, room_id: str, thread_root_event_id: str
-    ) -> Any:
+    async def unfollow_thread(self, room_id: str, thread_root_event_id: str) -> Any:
         data = await self._execute(
             Q.MUTATION_UNFOLLOW_THREAD,
             {"input": {"roomId": room_id, "threadRootEventId": thread_root_event_id}},
@@ -463,16 +616,16 @@ class ChattoClient:
         )
         return data["startDM"]
 
+    # --- Profile / account ---
+
     async def update_profile(
         self,
+        user_id: str,
         *,
-        user_id: str | None = None,
         display_name: str | None = None,
         login: str | None = None,
     ) -> User:
-        input_data: dict[str, Any] = {}
-        if user_id is not None:
-            input_data["userId"] = user_id
+        input_data: dict[str, Any] = {"userId": user_id}
         if display_name is not None:
             input_data["displayName"] = display_name
         if login is not None:
@@ -480,10 +633,8 @@ class ChattoClient:
         data = await self._execute(Q.MUTATION_UPDATE_PROFILE, {"input": input_data})
         return _parse_user(data["updateProfile"])
 
-    async def upload_avatar(self, file_path: str, user_id: str | None = None) -> dict[str, Any]:
-        variables: dict[str, Any] = {"input": {"file": None}}
-        if user_id is not None:
-            variables["input"]["userId"] = user_id
+    async def upload_avatar(self, file_path: str, user_id: str) -> dict[str, Any]:
+        variables: dict[str, Any] = {"input": {"userId": user_id, "file": None}}
         data = await self._execute_upload(
             Q.MUTATION_UPLOAD_AVATAR,
             variables,
@@ -491,18 +642,179 @@ class ChattoClient:
         )
         return data["uploadAvatar"]
 
-    async def update_presence(self, status: PresenceStatus) -> Any:
+    async def delete_avatar(self, user_id: str) -> dict[str, Any]:
+        data = await self._execute(Q.MUTATION_DELETE_AVATAR, {"input": {"userId": user_id}})
+        return data["deleteAvatar"]
+
+    async def update_presence(self, status: PresenceStatusInput | PresenceStatus) -> Any:
+        # The server's UpdateMyPresenceInput uses PresenceStatusInput, which omits OFFLINE.
+        if isinstance(status, PresenceStatus) and status == PresenceStatus.OFFLINE:
+            raise ValueError("OFFLINE is not a settable presence status")
+        data = await self._execute(Q.MUTATION_UPDATE_PRESENCE, {"input": {"status": status.value}})
+        return data["updateMyPresence"]
+
+    async def update_settings(
+        self,
+        user_id: str,
+        *,
+        timezone: str | None = None,
+        time_format: TimeFormat | None = None,
+    ) -> UserSettings:
+        input_data: dict[str, Any] = {"userId": user_id}
+        if timezone is not None:
+            input_data["timezone"] = timezone
+        if time_format is not None:
+            input_data["timeFormat"] = time_format.value
+        data = await self._execute(Q.MUTATION_UPDATE_SETTINGS, {"input": input_data})
+        return _parse_user_settings(data["updateSettings"]) or UserSettings()
+
+    async def request_account_deletion(self) -> Any:
+        data = await self._execute(Q.MUTATION_REQUEST_ACCOUNT_DELETION)
+        return data["requestAccountDeletion"]
+
+    async def delete_my_account(self, confirmation_token: str) -> Any:
         data = await self._execute(
-            Q.MUTATION_UPDATE_PRESENCE, {"input": {"status": status.value}}
+            Q.MUTATION_DELETE_MY_ACCOUNT,
+            {"input": {"confirmationToken": confirmation_token}},
         )
-        return data["updateMyPresence"]
+        return data["deleteMyAccount"]
+
+    # --- Notifications & push ---
+
+    async def set_server_notification_level(self, level: NotificationLevel) -> Any:
+        data = await self._execute(
+            Q.MUTATION_SET_SERVER_NOTIFICATION_LEVEL,
+            {"input": {"level": level.value}},
+        )
+        return data["setServerNotificationLevel"]
+
+    async def set_room_notification_level(self, room_id: str, level: NotificationLevel) -> Any:
+        data = await self._execute(
+            Q.MUTATION_SET_ROOM_NOTIFICATION_LEVEL,
+            {"input": {"roomId": room_id, "level": level.value}},
+        )
+        return data["setRoomNotificationLevel"]
 
     async def dismiss_notification(self, notification_id: str) -> Any:
         data = await self._execute(
-            Q.MUTATION_DISMISS_NOTIFICATION, {"input": {"notificationId": notification_id}}
+            Q.MUTATION_DISMISS_NOTIFICATION,
+            {"input": {"notificationId": notification_id}},
         )
         return data["dismissNotification"]
 
     async def dismiss_all_notifications(self) -> Any:
         data = await self._execute(Q.MUTATION_DISMISS_ALL_NOTIFICATIONS)
         return data["dismissAllNotifications"]
+
+    async def subscribe_to_push(
+        self,
+        endpoint: str,
+        p256dh: str,
+        auth: str,
+        user_agent: str | None = None,
+    ) -> Any:
+        input_data: dict[str, Any] = {
+            "endpoint": endpoint,
+            "p256dh": p256dh,
+            "auth": auth,
+        }
+        if user_agent is not None:
+            input_data["userAgent"] = user_agent
+        data = await self._execute(Q.MUTATION_SUBSCRIBE_TO_PUSH, {"input": input_data})
+        return data["subscribeToPush"]
+
+    async def unsubscribe_from_push(self, endpoint: str) -> Any:
+        data = await self._execute(
+            Q.MUTATION_UNSUBSCRIBE_FROM_PUSH, {"input": {"endpoint": endpoint}}
+        )
+        return data["unsubscribeFromPush"]
+
+    # --- Room groups ---
+
+    async def create_room_group(self, name: str, description: str | None = None) -> RoomGroup:
+        input_data: dict[str, Any] = {"name": name}
+        if description is not None:
+            input_data["description"] = description
+        data = await self._execute(Q.MUTATION_CREATE_ROOM_GROUP, {"input": input_data})
+        g = data["createRoomGroup"]
+        return RoomGroup(id=g["id"], name=g["name"], description=g.get("description", ""))
+
+    async def update_room_group(
+        self, group_id: str, name: str, description: str | None = None
+    ) -> RoomGroup:
+        input_data: dict[str, Any] = {"id": group_id, "name": name}
+        if description is not None:
+            input_data["description"] = description
+        data = await self._execute(Q.MUTATION_UPDATE_ROOM_GROUP, {"input": input_data})
+        g = data["updateRoomGroup"]
+        return RoomGroup(id=g["id"], name=g["name"], description=g.get("description", ""))
+
+    async def delete_room_group(self, group_id: str) -> Any:
+        data = await self._execute(Q.MUTATION_DELETE_ROOM_GROUP, {"input": {"id": group_id}})
+        return data["deleteRoomGroup"]
+
+    async def reorder_room_groups(self, ordered_ids: list[str]) -> Any:
+        data = await self._execute(
+            Q.MUTATION_REORDER_ROOM_GROUPS, {"input": {"orderedIds": ordered_ids}}
+        )
+        return data["reorderRoomGroups"]
+
+    async def move_room_to_group(self, room_id: str, group_id: str) -> Any:
+        data = await self._execute(
+            Q.MUTATION_MOVE_ROOM_TO_GROUP,
+            {"input": {"roomId": room_id, "groupId": group_id}},
+        )
+        return data["moveRoomToGroup"]
+
+    async def reorder_rooms_in_group(self, group_id: str, ordered_room_ids: list[str]) -> Any:
+        data = await self._execute(
+            Q.MUTATION_REORDER_ROOMS_IN_GROUP,
+            {"input": {"groupId": group_id, "orderedRoomIds": ordered_room_ids}},
+        )
+        return data["reorderRoomsInGroup"]
+
+    # --- Server admin (logo/banner/config) ---
+
+    async def upload_server_logo(self, file_path: str) -> dict[str, Any]:
+        data = await self._execute_upload(
+            Q.MUTATION_UPLOAD_SERVER_LOGO,
+            {"input": {"file": None}},
+            file_path,
+        )
+        return data["uploadServerLogo"]
+
+    async def delete_server_logo(self) -> dict[str, Any]:
+        data = await self._execute(Q.MUTATION_DELETE_SERVER_LOGO)
+        return data["deleteServerLogo"]
+
+    async def upload_server_banner(self, file_path: str) -> dict[str, Any]:
+        data = await self._execute_upload(
+            Q.MUTATION_UPLOAD_SERVER_BANNER,
+            {"input": {"file": None}},
+            file_path,
+        )
+        return data["uploadServerBanner"]
+
+    async def delete_server_banner(self) -> dict[str, Any]:
+        data = await self._execute(Q.MUTATION_DELETE_SERVER_BANNER)
+        return data["deleteServerBanner"]
+
+    async def update_server_config(
+        self,
+        *,
+        server_name: str | None = None,
+        welcome_message: str | None = None,
+        motd: str | None = None,
+        description: str | None = None,
+    ) -> dict[str, Any]:
+        input_data: dict[str, Any] = {}
+        if server_name is not None:
+            input_data["serverName"] = server_name
+        if welcome_message is not None:
+            input_data["welcomeMessage"] = welcome_message
+        if motd is not None:
+            input_data["motd"] = motd
+        if description is not None:
+            input_data["description"] = description
+        data = await self._execute(Q.MUTATION_UPDATE_SERVER_CONFIG, {"input": input_data})
+        return data["updateServerConfig"]

+ 334 - 66
src/chattolib/queries.py

@@ -12,6 +12,7 @@ query Me {
             createdAt
             avatarUrl
             presenceStatus
+            settings { timezone timeFormat }
         }
     }
 }
@@ -23,14 +24,19 @@ query Server {
         version
         enabledAuthProviders
         pushNotificationsEnabled
+        vapidPublicKey
         livekitUrl
         directRegistrationEnabled
+        videoProcessingEnabled
         maxUploadSize
         maxVideoUploadSize
         messageEditWindowSeconds
         memberCount
         roomCount
-        rooms { id type name description archived groupId hasUnread hasMention }
+        assetCount
+        profile { name logoUrl bannerUrl welcomeMessage motd description }
+        rooms { id type name description archived groupId hasUnread }
+        roomGroups { id name description rooms { id } }
     }
 }
 """
@@ -45,7 +51,6 @@ query Room($roomId: ID!) {
         archived
         groupId
         hasUnread
-        hasMention
     }
 }
 """
@@ -58,18 +63,26 @@ query RoomEvents($roomId: ID!, $limit: Int, $before: Time, $after: Time) {
                 id
                 createdAt
                 actorId
-                actor { id login displayName avatarUrl }
+                actor { id login displayName avatarUrl presenceStatus }
                 event {
                     ... on MessagePostedEvent {
                         roomId
                         body
-                        attachments { id filename contentType size url }
+                        updatedAt
+                        attachments {
+                            id roomId filename contentType size width height url thumbnailUrl
+                        }
                         reactions { emoji count hasReacted users { id login displayName } }
                         inReplyTo
-                        inThread
+                        threadRootEventId
                         replyCount
                         lastReplyAt
-                        linkPreview { url title description siteName }
+                        echoOfEventId
+                        echoFromThreadRootEventId
+                        viewerIsFollowingThread
+                        linkPreview {
+                            url title description imageUrl imageAssetId siteName embedType embedId
+                        }
                     }
                 }
             }
@@ -87,19 +100,29 @@ query ThreadEvents($roomId: ID!, $eventId: ID!) {
     room(roomId: $roomId) {
         event(eventId: $eventId) {
             id
-            threadReplies {
-                id
-                createdAt
-                actorId
-                actor { id login displayName avatarUrl }
-                event {
-                    ... on MessagePostedEvent {
-                        roomId
-                        body
-                        attachments { id filename contentType size url }
-                        reactions { emoji count hasReacted }
-                        inReplyTo
-                        inThread
+            event {
+                ... on MessagePostedEvent {
+                    threadReplies {
+                        events {
+                            id
+                            createdAt
+                            actorId
+                            actor { id login displayName avatarUrl presenceStatus }
+                            event {
+                                ... on MessagePostedEvent {
+                                    roomId
+                                    body
+                                    updatedAt
+                                    attachments {
+                                        id roomId filename contentType size
+                                        width height url thumbnailUrl
+                                    }
+                                    reactions { emoji count hasReacted }
+                                    inReplyTo
+                                    threadRootEventId
+                                }
+                            }
+                        }
                     }
                 }
             }
@@ -134,14 +157,14 @@ query UserByLogin($login: String!) {
 }
 """
 
-QUERY_USERS = """
-query Users {
-    users {
-        id
-        login
-        displayName
-        avatarUrl
-        presenceStatus
+QUERY_SERVER_MEMBERS = """
+query ServerMembers {
+    server {
+        members {
+            users { id login displayName avatarUrl presenceStatus }
+            totalCount
+            hasMore
+        }
     }
 }
 """
@@ -150,34 +173,46 @@ QUERY_NOTIFICATIONS = """
 query Notifications {
     viewer {
         notifications {
-            ... on MentionNotificationItem {
-                id
-                createdAt
-                summary
-                room { id name }
-                eventId
-            }
-            ... on ReplyNotificationItem {
-                id
-                createdAt
-                summary
-                room { id name }
-                eventId
-            }
-            ... on RoomMessageNotificationItem {
-                id
-                createdAt
-                summary
-                room { id name }
-                eventId
-            }
-            ... on DMMessageNotificationItem {
-                id
-                createdAt
-                summary
-                room { id name }
+            items {
+                ... on MentionNotificationItem {
+                    id
+                    createdAt
+                    summary
+                    actor { id login displayName avatarUrl }
+                    room { id name }
+                    eventId
+                    threadRootEventId
+                }
+                ... on ReplyNotificationItem {
+                    id
+                    createdAt
+                    summary
+                    actor { id login displayName avatarUrl }
+                    room { id name }
+                    eventId
+                    inReplyToId
+                    threadRootEventId
+                }
+                ... on RoomMessageNotificationItem {
+                    id
+                    createdAt
+                    summary
+                    actor { id login displayName avatarUrl }
+                    room { id name }
+                    eventId
+                }
+                ... on DMMessageNotificationItem {
+                    id
+                    createdAt
+                    summary
+                    actor { id login displayName avatarUrl }
+                    room { id name }
+                }
             }
+            totalCount
+            hasMore
         }
+        hasNotifications
     }
 }
 """
@@ -186,16 +221,35 @@ QUERY_FOLLOWED_THREADS = """
 query FollowedThreads {
     viewer {
         followedThreads {
-            roomId
-            threadRootEventId
-            replyCount
-            lastReplyAt
-            hasUnread
+            threads {
+                roomId
+                threadRootEventId
+                replyCount
+                lastReplyAt
+                hasUnread
+            }
+            totalCount
+            hasMore
         }
+        hasUnreadFollowedThreads
     }
 }
 """
 
+QUERY_LINK_PREVIEW = """
+query LinkPreview($url: String!) {
+    linkPreview(url: $url) {
+        url title description imageUrl imageAssetId siteName embedType embedId
+    }
+}
+"""
+
+QUERY_ACTIVE_CALL_ROOM_IDS = """
+query ActiveCallRoomIds {
+    activeCallRoomIds
+}
+"""
+
 # --- Mutations ---
 
 MUTATION_POST_MESSAGE = """
@@ -221,6 +275,18 @@ mutation DeleteMessage($input: DeleteMessageInput!) {
 }
 """
 
+MUTATION_DELETE_ATTACHMENT = """
+mutation DeleteAttachment($input: DeleteAttachmentInput!) {
+    deleteAttachment(input: $input)
+}
+"""
+
+MUTATION_DELETE_LINK_PREVIEW = """
+mutation DeleteLinkPreview($input: DeleteLinkPreviewInput!) {
+    deleteLinkPreview(input: $input)
+}
+"""
+
 MUTATION_ADD_REACTION = """
 mutation AddReaction($input: AddReactionInput!) {
     addReaction(input: $input)
@@ -240,10 +306,33 @@ mutation CreateRoom($input: CreateRoomInput!) {
         type
         name
         description
+        groupId
+    }
+}
+"""
+
+MUTATION_UPDATE_ROOM = """
+mutation UpdateRoom($input: UpdateRoomInput!) {
+    updateRoom(input: $input) {
+        id
+        name
+        description
     }
 }
 """
 
+MUTATION_ARCHIVE_ROOM = """
+mutation ArchiveRoom($input: ArchiveRoomInput!) {
+    archiveRoom(input: $input)
+}
+"""
+
+MUTATION_UNARCHIVE_ROOM = """
+mutation UnarchiveRoom($input: UnarchiveRoomInput!) {
+    unarchiveRoom(input: $input)
+}
+"""
+
 MUTATION_JOIN_ROOM = """
 mutation JoinRoom($input: JoinRoomInput!) {
     joinRoom(input: $input) {
@@ -259,10 +348,29 @@ mutation LeaveRoom($input: LeaveRoomInput!) {
 }
 """
 
+MUTATION_JOIN_GROUP = """
+mutation JoinGroup($input: JoinGroupInput!) {
+    joinGroup(input: $input)
+}
+"""
+
+MUTATION_BAN_ROOM_MEMBER = """
+mutation BanRoomMember($input: BanRoomMemberInput!) {
+    banRoomMember(input: $input)
+}
+"""
+
+MUTATION_UNBAN_ROOM_MEMBER = """
+mutation UnbanRoomMember($input: UnbanRoomMemberInput!) {
+    unbanRoomMember(input: $input)
+}
+"""
+
 MUTATION_MARK_ROOM_AS_READ = """
 mutation MarkRoomAsRead($input: MarkRoomAsReadInput!) {
     markRoomAsRead(input: $input) {
         lastReadAt
+        previousLastReadAt
     }
 }
 """
@@ -308,6 +416,7 @@ mutation UpdateProfile($input: UpdateProfileInput!) {
         id
         login
         displayName
+        avatarUrl
     }
 }
 """
@@ -321,12 +430,42 @@ mutation UploadAvatar($input: UploadAvatarInput!) {
 }
 """
 
+MUTATION_DELETE_AVATAR = """
+mutation DeleteAvatar($input: DeleteAvatarInput!) {
+    deleteAvatar(input: $input) {
+        id
+        avatarUrl
+    }
+}
+"""
+
 MUTATION_UPDATE_PRESENCE = """
 mutation UpdateMyPresence($input: UpdateMyPresenceInput!) {
     updateMyPresence(input: $input)
 }
 """
 
+MUTATION_UPDATE_SETTINGS = """
+mutation UpdateSettings($input: UpdateSettingsInput!) {
+    updateSettings(input: $input) {
+        timezone
+        timeFormat
+    }
+}
+"""
+
+MUTATION_SET_SERVER_NOTIFICATION_LEVEL = """
+mutation SetServerNotificationLevel($input: SetServerNotificationLevelInput!) {
+    setServerNotificationLevel(input: $input)
+}
+"""
+
+MUTATION_SET_ROOM_NOTIFICATION_LEVEL = """
+mutation SetRoomNotificationLevel($input: SetRoomNotificationLevelInput!) {
+    setRoomNotificationLevel(input: $input)
+}
+"""
+
 MUTATION_DISMISS_NOTIFICATION = """
 mutation DismissNotification($input: DismissNotificationInput!) {
     dismissNotification(input: $input)
@@ -339,6 +478,114 @@ mutation DismissAllNotifications {
 }
 """
 
+MUTATION_SUBSCRIBE_TO_PUSH = """
+mutation SubscribeToPush($input: PushSubscriptionInput!) {
+    subscribeToPush(input: $input)
+}
+"""
+
+MUTATION_UNSUBSCRIBE_FROM_PUSH = """
+mutation UnsubscribeFromPush($input: UnsubscribeFromPushInput!) {
+    unsubscribeFromPush(input: $input)
+}
+"""
+
+MUTATION_REQUEST_ACCOUNT_DELETION = """
+mutation RequestAccountDeletion {
+    requestAccountDeletion
+}
+"""
+
+MUTATION_DELETE_MY_ACCOUNT = """
+mutation DeleteMyAccount($input: DeleteMyAccountInput!) {
+    deleteMyAccount(input: $input)
+}
+"""
+
+MUTATION_CREATE_ROOM_GROUP = """
+mutation CreateRoomGroup($input: CreateRoomGroupInput!) {
+    createRoomGroup(input: $input) {
+        id
+        name
+        description
+    }
+}
+"""
+
+MUTATION_UPDATE_ROOM_GROUP = """
+mutation UpdateRoomGroup($input: UpdateRoomGroupInput!) {
+    updateRoomGroup(input: $input) {
+        id
+        name
+        description
+    }
+}
+"""
+
+MUTATION_DELETE_ROOM_GROUP = """
+mutation DeleteRoomGroup($input: DeleteRoomGroupInput!) {
+    deleteRoomGroup(input: $input)
+}
+"""
+
+MUTATION_REORDER_ROOM_GROUPS = """
+mutation ReorderRoomGroups($input: ReorderRoomGroupsInput!) {
+    reorderRoomGroups(input: $input)
+}
+"""
+
+MUTATION_MOVE_ROOM_TO_GROUP = """
+mutation MoveRoomToGroup($input: MoveRoomToGroupInput!) {
+    moveRoomToGroup(input: $input)
+}
+"""
+
+MUTATION_REORDER_ROOMS_IN_GROUP = """
+mutation ReorderRoomsInGroup($input: ReorderRoomsInGroupInput!) {
+    reorderRoomsInGroup(input: $input)
+}
+"""
+
+MUTATION_UPLOAD_SERVER_LOGO = """
+mutation UploadServerLogo($input: UploadServerLogoInput!) {
+    uploadServerLogo(input: $input) {
+        profile { logoUrl }
+    }
+}
+"""
+
+MUTATION_DELETE_SERVER_LOGO = """
+mutation DeleteServerLogo {
+    deleteServerLogo {
+        profile { logoUrl }
+    }
+}
+"""
+
+MUTATION_UPLOAD_SERVER_BANNER = """
+mutation UploadServerBanner($input: UploadServerBannerInput!) {
+    uploadServerBanner(input: $input) {
+        profile { bannerUrl }
+    }
+}
+"""
+
+MUTATION_DELETE_SERVER_BANNER = """
+mutation DeleteServerBanner {
+    deleteServerBanner {
+        profile { bannerUrl }
+    }
+}
+"""
+
+MUTATION_UPDATE_SERVER_CONFIG = """
+mutation UpdateServerConfig($input: UpdateServerConfigInput!) {
+    updateServerConfig(input: $input) {
+        profile { name welcomeMessage motd description }
+    }
+}
+"""
+
 # --- Subscriptions ---
 
 SUBSCRIPTION_EVENTS = """
@@ -347,11 +594,26 @@ subscription MyEvents {
         id
         createdAt
         actorId
-        actor { id login displayName avatarUrl }
+        actor { id login displayName avatarUrl presenceStatus }
         event {
-            ... on MessagePostedEvent { roomId body inThread }
-            ... on MessageUpdatedEvent { roomId messageEventId }
-            ... on MessageDeletedEvent { roomId messageEventId }
+            ... on MessagePostedEvent {
+                roomId
+                body
+                inReplyTo
+                threadRootEventId
+                updatedAt
+                attachments { id filename contentType size width height url thumbnailUrl }
+                linkPreview { url title description imageUrl siteName }
+            }
+            ... on MessageEditedEvent {
+                roomId
+                messageEventId
+                body
+                updatedAt
+                attachments { id filename contentType size width height url thumbnailUrl }
+                linkPreview { url title description imageUrl siteName }
+            }
+            ... on MessageRetractedEvent { roomId messageEventId reason }
             ... on ReactionAddedEvent { roomId messageEventId emoji }
             ... on ReactionRemovedEvent { roomId messageEventId emoji }
             ... on UserTypingEvent { roomId threadRootEventId }
@@ -362,25 +624,31 @@ subscription MyEvents {
             ... on RoomUnarchivedEvent { roomId }
             ... on UserJoinedRoomEvent { roomId }
             ... on UserLeftRoomEvent { roomId }
+            ... on RoomMemberBannedEvent { roomId userId }
+            ... on RoomMemberUnbannedEvent { roomId userId }
             ... on ServerMemberDeletedEvent { userId }
+            ... on ThreadCreatedEvent { roomId threadRootEventId }
+            ... on AssetProcessingStartedEvent { roomId assetId messageEventId }
+            ... on AssetProcessingSucceededEvent { roomId assetId messageEventId }
+            ... on AssetProcessingFailedEvent { roomId assetId messageEventId reasonCode }
+            ... on AssetDeletedEvent { roomId assetId }
             ... on CallParticipantJoinedEvent { roomId }
             ... on CallParticipantLeftEvent { roomId }
-            ... on VideoProcessingCompletedEvent { roomId attachmentId messageEventId }
             ... on PresenceChangedEvent { status }
-            ... on ServerConfigUpdatedEvent { serverName motd }
             ... on UserCreatedEvent { userId login displayName }
             ... on UserDeletedEvent { userId }
             ... on UserProfileUpdatedEvent { userId displayName avatarUrl login }
             ... on ServerUserPreferencesUpdatedEvent { timezone timeFormat }
             ... on NotificationLevelChangedEvent { roomId level effectiveLevel }
-            ... on ServerUpdatedEvent { name description }
+            ... on ServerUpdatedEvent { name description logoUrl bannerUrl }
             ... on MentionNotificationEvent { roomId }
+            ... on MentionStatusClearedEvent { roomId }
             ... on NewDirectMessageNotificationEvent { roomId conversationName }
             ... on NotificationCreatedEvent { notificationId roomId eventId inReplyToId }
             ... on NotificationDismissedEvent { notificationId }
             ... on ThreadFollowChangedEvent { roomId threadRootEventId isFollowing }
             ... on RoomMarkedAsReadEvent { roomId }
-            ... on RoomLayoutUpdatedEvent { changed }
+            ... on RoomGroupsUpdatedEvent { changed }
             ... on SessionTerminatedEvent { reason }
             ... on HeartbeatEvent { alive }
         }

+ 2 - 1
src/chattolib/subscriptions.py

@@ -4,7 +4,8 @@ from __future__ import annotations
 
 import asyncio
 import json
-from typing import Any, AsyncIterator
+from collections.abc import AsyncIterator
+from typing import Any
 
 import websockets
 from websockets.asyncio.client import ClientConnection

+ 85 - 10
src/chattolib/types.py

@@ -6,7 +6,6 @@ from dataclasses import dataclass, field
 from datetime import datetime
 from enum import Enum
 
-
 # --- Enums ---
 
 
@@ -24,12 +23,22 @@ class NotificationLevel(str, Enum):
 
 
 class PresenceStatus(str, Enum):
+    """User presence status as observed on the server."""
+
     OFFLINE = "OFFLINE"
     ONLINE = "ONLINE"
     AWAY = "AWAY"
     DO_NOT_DISTURB = "DO_NOT_DISTURB"
 
 
+class PresenceStatusInput(str, Enum):
+    """User-settable presence status (server cannot be told OFFLINE)."""
+
+    ONLINE = "ONLINE"
+    AWAY = "AWAY"
+    DO_NOT_DISTURB = "DO_NOT_DISTURB"
+
+
 class RoomType(str, Enum):
     CHANNEL = "CHANNEL"
     DM = "DM"
@@ -51,14 +60,39 @@ class VideoProcessingStatus(str, Enum):
 # --- Core types ---
 
 
+@dataclass
+class UserSettings:
+    timezone: str | None = None
+    time_format: TimeFormat = TimeFormat.UNSPECIFIED
+
+
 @dataclass
 class User:
     id: str
     login: str
     display_name: str
+    presence_status: PresenceStatus = PresenceStatus.OFFLINE
     created_at: datetime | None = None
     avatar_url: str | None = None
-    presence_status: PresenceStatus | None = None
+    settings: UserSettings | None = None
+
+
+@dataclass
+class ServerProfile:
+    name: str
+    logo_url: str | None = None
+    banner_url: str | None = None
+    welcome_message: str | None = None
+    motd: str | None = None
+    description: str | None = None
+
+
+@dataclass
+class RoomGroup:
+    id: str
+    name: str
+    description: str = ""
+    room_ids: list[str] = field(default_factory=list)
 
 
 @dataclass
@@ -70,7 +104,23 @@ class Room:
     archived: bool = False
     group_id: str | None = None
     has_unread: bool = False
-    has_mention: bool = False
+
+
+@dataclass
+class RoomBan:
+    id: str
+    room_id: str
+    user_id: str
+    moderator_id: str
+    reason: str
+    created_at: datetime | None = None
+    expires_at: datetime | None = None
+
+
+@dataclass
+class AssetURL:
+    url: str
+    expires_at: datetime | None = None
 
 
 @dataclass
@@ -80,9 +130,10 @@ class Attachment:
     filename: str
     content_type: str
     size: int
-    url: str | None = None
-    width: int | None = None
-    height: int | None = None
+    width: int = 0
+    height: int = 0
+    url: str = ""
+    thumbnail_url: str | None = None
 
 
 @dataclass
@@ -91,34 +142,44 @@ class LinkPreview:
     title: str | None = None
     description: str | None = None
     image_url: str | None = None
+    image_asset_id: str | None = None
     site_name: str | None = None
     embed_type: str | None = None
     embed_id: str | None = None
 
 
 @dataclass
-class Reaction:
+class ReactionSummary:
     emoji: str
     count: int
     has_reacted: bool = False
     users: list[User] = field(default_factory=list)
 
 
+# Backwards-compatible alias for the renamed Reaction type.
+Reaction = ReactionSummary
+
+
 @dataclass
 class MessageEvent:
-    """A message event from roomEvents / threadEvents."""
+    """A message event from roomEvents / threadReplies."""
 
     id: str
     room_id: str
     body: str | None = None
     created_at: datetime | None = None
+    updated_at: datetime | None = None
     actor: User | None = None
     attachments: list[Attachment] = field(default_factory=list)
-    reactions: list[Reaction] = field(default_factory=list)
+    reactions: list[ReactionSummary] = field(default_factory=list)
     in_reply_to: str | None = None
-    in_thread: str | None = None
+    thread_root_event_id: str | None = None
     reply_count: int = 0
+    last_reply_at: datetime | None = None
     link_preview: LinkPreview | None = None
+    echo_of_event_id: str | None = None
+    echo_from_thread_root_event_id: str | None = None
+    viewer_is_following_thread: bool | None = None
 
 
 @dataclass
@@ -137,3 +198,17 @@ class FollowedThread:
     reply_count: int = 0
     last_reply_at: datetime | None = None
     has_unread: bool = False
+
+
+@dataclass
+class FollowedThreadsPage:
+    threads: list[FollowedThread]
+    total_count: int = 0
+    has_more: bool = False
+
+
+@dataclass
+class NotificationsPage:
+    items: list[dict[str, object]]
+    total_count: int = 0
+    has_more: bool = False

+ 140 - 35
tests/test_client.py

@@ -1,12 +1,12 @@
 """Tests for ChattoClient using respx to mock HTTP."""
 
+import httpx
 import pytest
 import respx
-import httpx
 
 from chattolib.client import ChattoClient
-from chattolib.exceptions import ChattoGraphQLError, ChattoAuthError
-from chattolib.types import PresenceStatus
+from chattolib.exceptions import ChattoAuthError, ChattoGraphQLError
+from chattolib.types import PresenceStatusInput
 
 
 @pytest.fixture
@@ -33,9 +33,10 @@ async def test_me(mock_api, client):
                         "id": "u1",
                         "login": "alice",
                         "displayName": "Alice",
-                        "createdAt": "2025-01-01T00:00:00Z",
+                        "createdAt": "2025-01-01T00:00:00",
                         "avatarUrl": None,
                         "presenceStatus": "ONLINE",
+                        "settings": None,
                     }
                 }
             }
@@ -62,7 +63,6 @@ async def test_rooms(mock_api, client):
                             "archived": False,
                             "groupId": "g1",
                             "hasUnread": False,
-                            "hasMention": False,
                         }
                     ]
                 }
@@ -98,7 +98,7 @@ async def test_auth_error(mock_api, client):
 async def test_post_message(mock_api, client):
     mock_api.post("/api/graphql").mock(
         return_value=_gql_response(
-            {"postMessage": {"id": "e1", "createdAt": "2025-01-01T00:00:00Z"}}
+            {"postMessage": {"id": "e1", "createdAt": "2025-01-01T00:00:00"}}
         )
     )
     async with client:
@@ -115,23 +115,28 @@ async def test_room_events(mock_api, client):
                         "events": [
                             {
                                 "id": "e1",
-                                "createdAt": "2025-01-01T00:00:00Z",
+                                "createdAt": "2025-01-01T00:00:00",
                                 "actorId": "u1",
                                 "actor": {
                                     "id": "u1",
                                     "login": "alice",
                                     "displayName": "Alice",
                                     "avatarUrl": None,
+                                    "presenceStatus": "ONLINE",
                                 },
                                 "event": {
                                     "roomId": "r1",
                                     "body": "Hello",
+                                    "updatedAt": None,
                                     "attachments": [],
                                     "reactions": [],
                                     "inReplyTo": None,
-                                    "inThread": None,
+                                    "threadRootEventId": None,
                                     "replyCount": 0,
                                     "lastReplyAt": None,
+                                    "echoOfEventId": None,
+                                    "echoFromThreadRootEventId": None,
+                                    "viewerIsFollowingThread": None,
                                     "linkPreview": None,
                                 },
                             }
@@ -150,6 +155,7 @@ async def test_room_events(mock_api, client):
     assert len(page.events) == 1
     assert page.events[0].body == "Hello"
     assert page.events[0].actor.login == "alice"
+    assert page.events[0].thread_root_event_id is None
 
 
 async def test_login():
@@ -157,7 +163,11 @@ async def test_login():
         api.post("/auth/login").mock(
             return_value=httpx.Response(
                 200,
-                json={"success": True, "token": "cht_abc123", "user": {"id": "u1", "login": "alice"}},
+                json={
+                    "success": True,
+                    "token": "cht_abc123",
+                    "user": {"id": "u1", "login": "alice"},
+                },
                 headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
             )
         )
@@ -169,42 +179,36 @@ async def test_login():
 
 async def test_login_invalid():
     with respx.mock(base_url="https://chat.chatto.run") as api:
-        api.post("/auth/login").mock(return_value=httpx.Response(401, json={"error": "Invalid credentials"}))
+        api.post("/auth/login").mock(
+            return_value=httpx.Response(401, json={"error": "Invalid credentials"})
+        )
         with pytest.raises(ChattoAuthError, match="Invalid credentials"):
             await ChattoClient.login("bad", "creds")
 
 
 async def test_update_message(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"updateMessage": {"id": "e1"}})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"updateMessage": {"id": "e1"}}))
     async with client:
         result = await client.update_message("r1", "e1", "edited body")
     assert result["id"] == "e1"
 
 
 async def test_delete_message(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"deleteMessage": True})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"deleteMessage": True}))
     async with client:
         result = await client.delete_message("r1", "e1")
     assert result is True
 
 
 async def test_add_reaction(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"addReaction": True})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"addReaction": True}))
     async with client:
         result = await client.add_reaction("r1", "e1", "thumbsup")
     assert result is True
 
 
 async def test_remove_reaction(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"removeReaction": True})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"removeReaction": True}))
     async with client:
         result = await client.remove_reaction("r1", "e1", "thumbsup")
     assert result is True
@@ -213,13 +217,22 @@ async def test_remove_reaction(mock_api, client):
 async def test_create_room(mock_api, client):
     mock_api.post("/api/graphql").mock(
         return_value=_gql_response(
-            {"createRoom": {"id": "r1", "type": "CHANNEL", "name": "general", "description": None}}
+            {
+                "createRoom": {
+                    "id": "r1",
+                    "type": "CHANNEL",
+                    "name": "general",
+                    "description": None,
+                    "groupId": "g1",
+                }
+            }
         )
     )
     async with client:
-        room = await client.create_room("general")
+        room = await client.create_room("general", "g1")
     assert room.id == "r1"
     assert room.name == "general"
+    assert room.group_id == "g1"
 
 
 async def test_update_profile(mock_api, client):
@@ -230,22 +243,22 @@ async def test_update_profile(mock_api, client):
                     "id": "u1",
                     "login": "newname",
                     "displayName": "New Name",
+                    "avatarUrl": None,
+                    "presenceStatus": "ONLINE",
                 }
             }
         )
     )
     async with client:
-        user = await client.update_profile(login="newname", display_name="New Name")
+        user = await client.update_profile("u1", login="newname", display_name="New Name")
     assert user.login == "newname"
     assert user.display_name == "New Name"
 
 
 async def test_update_presence(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"updateMyPresence": True})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"updateMyPresence": True}))
     async with client:
-        result = await client.update_presence(PresenceStatus.ONLINE)
+        result = await client.update_presence(PresenceStatusInput.ONLINE)
     assert result is True
 
 
@@ -258,7 +271,7 @@ async def test_upload_avatar(mock_api, client, tmp_path):
     avatar = tmp_path / "avatar.jpg"
     avatar.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
     async with client:
-        result = await client.upload_avatar(str(avatar))
+        result = await client.upload_avatar(str(avatar), "u1")
     assert result["avatarUrl"] == "https://example.com/avatar.jpg"
 
 
@@ -270,7 +283,7 @@ async def test_user(mock_api, client):
                     "id": "u1",
                     "login": "alice",
                     "displayName": "Alice",
-                    "createdAt": "2025-01-01T00:00:00Z",
+                    "createdAt": "2025-01-01T00:00:00",
                     "avatarUrl": None,
                     "presenceStatus": "ONLINE",
                 }
@@ -293,7 +306,7 @@ async def test_user_by_login(mock_api, client):
                     "displayName": "Alice",
                     "createdAt": None,
                     "avatarUrl": None,
-                    "presenceStatus": None,
+                    "presenceStatus": "OFFLINE",
                 }
             }
         )
@@ -313,9 +326,7 @@ async def test_start_dm(mock_api, client):
 
 
 async def test_dismiss_notification(mock_api, client):
-    mock_api.post("/api/graphql").mock(
-        return_value=_gql_response({"dismissNotification": True})
-    )
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"dismissNotification": True}))
     async with client:
         result = await client.dismiss_notification("n1")
     assert result is True
@@ -328,3 +339,97 @@ async def test_dismiss_all_notifications(mock_api, client):
     async with client:
         result = await client.dismiss_all_notifications()
     assert result is True
+
+
+async def test_followed_threads(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "viewer": {
+                    "followedThreads": {
+                        "threads": [
+                            {
+                                "roomId": "r1",
+                                "threadRootEventId": "e1",
+                                "replyCount": 3,
+                                "lastReplyAt": "2025-01-01T00:00:00",
+                                "hasUnread": True,
+                            }
+                        ],
+                        "totalCount": 1,
+                        "hasMore": False,
+                    },
+                    "hasUnreadFollowedThreads": True,
+                }
+            }
+        )
+    )
+    async with client:
+        page = await client.followed_threads()
+    assert page.total_count == 1
+    assert page.threads[0].room_id == "r1"
+    assert page.threads[0].has_unread is True
+
+
+async def test_notifications(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "viewer": {
+                    "notifications": {
+                        "items": [
+                            {
+                                "id": "n1",
+                                "createdAt": "2025-01-01T00:00:00",
+                                "summary": "Mention",
+                                "actor": None,
+                                "room": {"id": "r1", "name": "general"},
+                                "eventId": "e1",
+                                "threadRootEventId": None,
+                            }
+                        ],
+                        "totalCount": 1,
+                        "hasMore": False,
+                    },
+                    "hasNotifications": True,
+                }
+            }
+        )
+    )
+    async with client:
+        page = await client.notifications()
+    assert page.total_count == 1
+    assert page.items[0]["id"] == "n1"
+
+
+async def test_archive_unarchive_room(mock_api, client):
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"archiveRoom": True}))
+    async with client:
+        assert await client.archive_room("r1") is True
+
+
+async def test_create_room_group(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {"createRoomGroup": {"id": "g1", "name": "Team", "description": ""}}
+        )
+    )
+    async with client:
+        group = await client.create_room_group("Team")
+    assert group.id == "g1"
+    assert group.name == "Team"
+
+
+async def test_ban_room_member(mock_api, client):
+    mock_api.post("/api/graphql").mock(return_value=_gql_response({"banRoomMember": True}))
+    async with client:
+        result = await client.ban_room_member("r1", "u2", "spam")
+    assert result is True
+
+
+async def test_update_presence_offline_rejected(client):
+    from chattolib.types import PresenceStatus
+
+    async with client:
+        with pytest.raises(ValueError):
+            await client.update_presence(PresenceStatus.OFFLINE)

+ 0 - 1
tests/test_integration.py

@@ -11,7 +11,6 @@ import pytest
 from chattolib.client import ChattoClient
 from chattolib.exceptions import ChattoAuthError
 
-
 pytestmark = pytest.mark.skipif(
     not os.environ.get("CHATTO_LOGIN") or not os.environ.get("CHATTO_PASSWORD"),
     reason="CHATTO_LOGIN and CHATTO_PASSWORD env vars required",