Bladeren bron

Update for Chatto v0.0.171: spaces removed, single-server model

- Remove Space type and all space-related methods
- Remove spaceId from all queries, mutations, and inputs
- mySpaceEvents → myServerEvents subscription (no spaceId arg)
- Room gains type field (CHANNEL/DM), loses spaceId
- RoomEventsPage gains start_cursor/end_cursor
- Add instance() query and rooms() method
- Update all tests and docs
TheCodemancer 5 maanden geleden
bovenliggende
commit
223bca61a8
8 gewijzigde bestanden met toevoegingen van 148 en 208 verwijderingen
  1. 8 7
      CLAUDE.md
  2. 1 1
      pyproject.toml
  3. 42 67
      src/chattolib/client.py
  4. 49 80
      src/chattolib/queries.py
  5. 5 7
      src/chattolib/subscriptions.py
  6. 8 16
      src/chattolib/types.py
  7. 29 24
      tests/test_client.py
  8. 6 6
      tests/test_integration.py

+ 8 - 7
CLAUDE.md

@@ -33,24 +33,24 @@ The library uses **httpx** for async HTTP and **websockets** for GraphQL subscri
 ### Package layout: `src/chattolib/`
 
 - **client.py** — Main `ChattoClient` async class. Holds the httpx session, auth token, and base URL. All API methods live here or are mixed in from domain modules.
-- **types.py** — Dataclasses / TypedDicts mirroring GraphQL object types (Space, Room, User, Message, Attachment, etc.). Field names are snake_case translations of the schema's camelCase.
+- **types.py** — Dataclasses mirroring GraphQL object types (Room, User, Message, Attachment, etc.). Field names are snake_case translations of the schema's camelCase.
 - **queries.py** — Raw GraphQL query/mutation/subscription strings as constants.
-- **subscriptions.py** — WebSocket subscription handling for real-time events (space events, instance events, typing indicators, presence changes, etc.).
+- **subscriptions.py** — WebSocket subscription handling for real-time events (server events, instance events, typing indicators, presence changes, etc.).
 - **exceptions.py** — Library-specific exception hierarchy wrapping GraphQL error responses.
 
 ### Key API domains (from the Chatto GraphQL schema)
 
 | Domain | Queries | Mutations | Subscriptions |
 |---|---|---|---|
-| **Spaces** | `spaces`, `space(id)` | `updateSpace`, `joinSpace`, `leaveSpace`, logo/banner uploads | `mySpaceEvents` |
-| **Rooms** | `room`, `roomEvents`, `roomEventByEventId`, `threadEvents`, `roomEventsAround` | `createRoom`, `updateRoom`, `archiveRoom`, `joinRoom`, `leaveRoom`, `markRoomAsRead` | via space events |
+| **Instance** | `instance` | `updateInstance`, logo/banner uploads | `myInstanceEvents` |
+| **Rooms** | `room`, `roomEvents`, `roomEventByEventId`, `threadEvents`, `roomEventsAround` | `createRoom`, `updateRoom`, `archiveRoom`, `joinRoom`, `leaveRoom`, `markRoomAsRead` | `myServerEvents` |
 | **Messages** | (via roomEvents) | `postMessage`, `editMessage`, `deleteMessage` | `MessagePostedEvent`, `MessageUpdatedEvent`, `MessageDeletedEvent` |
 | **Reactions** | (on message events) | `addReaction`, `removeReaction` | `ReactionAddedEvent`, `ReactionRemovedEvent` |
 | **Threads** | `threadEvents`, `myFollowedThreads` | `followThread`, `unfollowThread`, `markThreadAsOpened` | `ThreadFollowChangedEvent` |
 | **Users** | `me`, `user(id)`, `userByLogin`, `users` | `updateMyProfile`, `uploadMyAvatar`, `deleteMyAvatar` | `UserProfileUpdatedEvent`, `PresenceChangedEvent` |
 | **DMs** | (via rooms) | `startDM` | `NewDirectMessageNotificationEvent` |
 | **Notifications** | `notifications`, `hasNotifications` | `dismissNotification`, `dismissAllNotifications` | `NotificationCreatedEvent`, `NotificationDismissedEvent` |
-| **Permissions/Roles** | via `admin` query, `space.roles` | `grantInstancePermission`, `createRole`, `assignInstanceRole`, space-level equivalents | — |
+| **Permissions/Roles** | via `admin` query, `instance.roles` | `grantInstancePermission`, `createRole`, `assignInstanceRole` | — |
 | **Voice calls** | `voiceCallToken`, `activeCallRoomIds`, `callParticipants` | — | `CallParticipantJoinedEvent`, `CallParticipantLeftEvent` |
 | **Admin** | `admin.systemInfo`, `admin.instanceConfig`, `admin.roles` | `admin.updateInstanceConfig`, `admin.resetInstanceConfig` | `myInstanceEvents` |
 
@@ -59,9 +59,10 @@ The library uses **httpx** for async HTTP and **websockets** for GraphQL subscri
 - IDs are opaque strings (`ID` scalar).
 - Large integers use `Int64` scalar (e.g., byte counts) — map to Python `int`.
 - File uploads use a custom `Upload` scalar (multipart form).
-- Pagination uses `limit`/`before`/`after` on room events (`before`/`after` are `Time` scalars, ISO timestamps); `limit`/`offset` on space members.
+- 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.
-- Subscriptions use `spaceId` scoping (`mySpaceEvents`) or are instance-wide (`myInstanceEvents`).
+- Subscriptions: `myServerEvents` for room/message events, `myInstanceEvents` for instance-wide events.
+- Rooms have a `type` field: `CHANNEL` or `DM`.
 - 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.150a0"
+version = "0.0.171a0"
 description = "Async Python client library for the Chatto webchat GraphQL API"
 requires-python = ">=3.11"
 license = "MIT"

+ 42 - 67
src/chattolib/client.py

@@ -18,7 +18,7 @@ from chattolib.types import (
     Reaction,
     Room,
     RoomEventsPage,
-    Space,
+    RoomType,
     User,
 )
 
@@ -40,24 +40,11 @@ def _parse_user(data: dict[str, Any]) -> User:
     )
 
 
-def _parse_space(data: dict[str, Any]) -> Space:
-    return Space(
-        id=data["id"],
-        name=data["name"],
-        description=data.get("description"),
-        logo_url=data.get("logoUrl"),
-        banner_url=data.get("bannerUrl"),
-        member_count=data.get("memberCount", 0),
-        room_count=data.get("roomCount", 0),
-        viewer_is_member=data.get("viewerIsMember", False),
-    )
-
-
 def _parse_room(data: dict[str, Any]) -> Room:
     return Room(
         id=data["id"],
-        space_id=data["spaceId"],
         name=data["name"],
+        type=RoomType(data["type"]) if data.get("type") else None,
         description=data.get("description"),
         archived=data.get("archived", False),
         auto_join=data.get("autoJoin", False),
@@ -69,7 +56,6 @@ def _parse_room(data: dict[str, Any]) -> Room:
 def _parse_attachment(data: dict[str, Any]) -> Attachment:
     return Attachment(
         id=data["id"],
-        space_id=data.get("spaceId", ""),
         room_id=data.get("roomId", ""),
         filename=data["filename"],
         content_type=data["contentType"],
@@ -94,7 +80,6 @@ def _parse_message_event(data: dict[str, Any]) -> MessageEvent:
     actor_data = data.get("actor")
     return MessageEvent(
         id=data["id"],
-        space_id=event.get("spaceId", ""),
         room_id=event.get("roomId", ""),
         body=event.get("body"),
         created_at=_parse_datetime(data.get("createdAt")),
@@ -125,9 +110,9 @@ class ChattoClient:
 
     Usage::
 
-        async with ChattoClient.login("user", "pass") as client:
+        async with await ChattoClient.login("user", "pass") as client:
             me = await client.me()
-            spaces = await client.spaces()
+            rooms = await client.rooms()
 
         # Or with a token directly:
         async with ChattoClient(token="cht_...") as client:
@@ -255,12 +240,13 @@ class ChattoClient:
         if response.status_code == 401:
             raise ChattoAuthError("Authentication failed")
 
-        response.raise_for_status()
         body = response.json()
 
         if "errors" in body:
             raise ChattoGraphQLError(body["errors"], data=body.get("data"))
 
+        response.raise_for_status()
+
         return body["data"]
 
     # --- Queries ---
@@ -269,28 +255,28 @@ class ChattoClient:
         data = await self._execute(Q.QUERY_ME)
         return _parse_user(data["me"])
 
-    async def spaces(self) -> list[Space]:
-        data = await self._execute(Q.QUERY_SPACES)
-        return [_parse_space(s) for s in data["spaces"]]
+    async def instance(self) -> dict[str, Any]:
+        data = await self._execute(Q.QUERY_INSTANCE)
+        return data["instance"]
 
-    async def space(self, space_id: str) -> Space:
-        data = await self._execute(Q.QUERY_SPACE, {"id": space_id})
-        return _parse_space(data["space"])
+    async def rooms(self) -> list[Room]:
+        """Get all rooms from the instance."""
+        data = await self._execute(Q.QUERY_INSTANCE)
+        return [_parse_room(r) for r in data["instance"]["rooms"]]
 
-    async def room(self, space_id: str, room_id: str) -> Room:
-        data = await self._execute(Q.QUERY_ROOM, {"spaceId": space_id, "roomId": room_id})
+    async def room(self, room_id: str) -> Room:
+        data = await self._execute(Q.QUERY_ROOM, {"roomId": room_id})
         return _parse_room(data["room"])
 
     async def room_events(
         self,
-        space_id: str,
         room_id: str,
         *,
         limit: int | None = None,
         before: str | None = None,
         after: str | None = None,
     ) -> RoomEventsPage:
-        variables: dict[str, Any] = {"spaceId": space_id, "roomId": room_id}
+        variables: dict[str, Any] = {"roomId": room_id}
         if limit is not None:
             variables["limit"] = limit
         if before is not None:
@@ -303,17 +289,18 @@ class ChattoClient:
             events=[_parse_message_event(e) for e in conn["events"]],
             has_older=conn["hasOlder"],
             has_newer=conn["hasNewer"],
+            start_cursor=conn.get("startCursor"),
+            end_cursor=conn.get("endCursor"),
         )
 
     async def thread_events(
         self,
-        space_id: str,
         room_id: str,
         thread_root_event_id: str,
     ) -> list[MessageEvent]:
         data = await self._execute(
             Q.QUERY_THREAD_EVENTS,
-            {"spaceId": space_id, "roomId": room_id, "threadRootEventId": thread_root_event_id},
+            {"roomId": room_id, "threadRootEventId": thread_root_event_id},
         )
         return [_parse_message_event(e) for e in data["threadEvents"]]
 
@@ -333,11 +320,10 @@ class ChattoClient:
         data = await self._execute(Q.QUERY_NOTIFICATIONS)
         return data["notifications"]
 
-    async def followed_threads(self, space_id: str) -> list[FollowedThread]:
-        data = await self._execute(Q.QUERY_FOLLOWED_THREADS, {"spaceId": space_id})
+    async def followed_threads(self) -> list[FollowedThread]:
+        data = await self._execute(Q.QUERY_FOLLOWED_THREADS)
         return [
             FollowedThread(
-                space_id=t["spaceId"],
                 room_id=t["roomId"],
                 thread_root_event_id=t["threadRootEventId"],
                 reply_count=t.get("replyCount", 0),
@@ -351,7 +337,6 @@ class ChattoClient:
 
     async def post_message(
         self,
-        space_id: str,
         room_id: str,
         body: str,
         *,
@@ -360,7 +345,6 @@ class ChattoClient:
         also_send_to_channel: bool | None = None,
     ) -> dict[str, Any]:
         input_data: dict[str, Any] = {
-            "spaceId": space_id,
             "roomId": room_id,
             "body": body,
         }
@@ -375,99 +359,90 @@ class ChattoClient:
 
     async def edit_message(
         self,
-        space_id: str,
         room_id: str,
         event_id: str,
         body: str,
     ) -> dict[str, Any]:
         data = await self._execute(
             Q.MUTATION_EDIT_MESSAGE,
-            {"input": {"spaceId": space_id, "roomId": room_id, "eventId": event_id, "body": body}},
+            {"input": {"roomId": room_id, "eventId": event_id, "body": body}},
         )
         return data["editMessage"]
 
-    async def delete_message(self, space_id: str, room_id: str, event_id: str) -> Any:
+    async def delete_message(self, room_id: str, event_id: str) -> Any:
         data = await self._execute(
             Q.MUTATION_DELETE_MESSAGE,
-            {"input": {"spaceId": space_id, "roomId": room_id, "eventId": event_id}},
+            {"input": {"roomId": room_id, "eventId": event_id}},
         )
         return data["deleteMessage"]
 
     async def add_reaction(
-        self, space_id: str, room_id: str, message_event_id: str, emoji: str
+        self, room_id: str, message_event_id: str, emoji: str
     ) -> Any:
         data = await self._execute(
             Q.MUTATION_ADD_REACTION,
-            {"input": {"spaceId": space_id, "roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
+            {"input": {"roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
         )
         return data["addReaction"]
 
     async def remove_reaction(
-        self, space_id: str, room_id: str, message_event_id: str, emoji: str
+        self, room_id: str, message_event_id: str, emoji: str
     ) -> Any:
         data = await self._execute(
             Q.MUTATION_REMOVE_REACTION,
-            {"input": {"spaceId": space_id, "roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
+            {"input": {"roomId": room_id, "messageEventId": message_event_id, "emoji": emoji}},
         )
         return data["removeReaction"]
 
-    async def join_space(self, space_id: str) -> dict[str, Any]:
-        data = await self._execute(Q.MUTATION_JOIN_SPACE, {"input": {"spaceId": space_id}})
-        return data["joinSpace"]
-
-    async def leave_space(self, space_id: str) -> Any:
-        data = await self._execute(Q.MUTATION_LEAVE_SPACE, {"input": {"spaceId": space_id}})
-        return data["leaveSpace"]
-
     async def create_room(
-        self, space_id: str, name: str, description: str | None = None
+        self, name: str, description: str | None = None
     ) -> Room:
-        input_data: dict[str, Any] = {"spaceId": space_id, "name": name}
+        input_data: dict[str, Any] = {"name": name}
         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 join_room(self, space_id: str, room_id: str) -> dict[str, Any]:
+    async def join_room(self, room_id: str) -> dict[str, Any]:
         data = await self._execute(
-            Q.MUTATION_JOIN_ROOM, {"input": {"spaceId": space_id, "roomId": room_id}}
+            Q.MUTATION_JOIN_ROOM, {"input": {"roomId": room_id}}
         )
         return data["joinRoom"]
 
-    async def leave_room(self, space_id: str, room_id: str) -> Any:
+    async def leave_room(self, room_id: str) -> Any:
         data = await self._execute(
-            Q.MUTATION_LEAVE_ROOM, {"input": {"spaceId": space_id, "roomId": room_id}}
+            Q.MUTATION_LEAVE_ROOM, {"input": {"roomId": room_id}}
         )
         return data["leaveRoom"]
 
-    async def mark_room_as_read(self, space_id: str, room_id: str) -> dict[str, Any]:
+    async def mark_room_as_read(self, room_id: str) -> dict[str, Any]:
         data = await self._execute(
-            Q.MUTATION_MARK_ROOM_AS_READ, {"input": {"spaceId": space_id, "roomId": room_id}}
+            Q.MUTATION_MARK_ROOM_AS_READ, {"input": {"roomId": room_id}}
         )
         return data["markRoomAsRead"]
 
     async def follow_thread(
-        self, space_id: str, room_id: str, thread_root_event_id: str
+        self, room_id: str, thread_root_event_id: str
     ) -> Any:
         data = await self._execute(
             Q.MUTATION_FOLLOW_THREAD,
-            {"input": {"spaceId": space_id, "roomId": room_id, "threadRootEventId": thread_root_event_id}},
+            {"input": {"roomId": room_id, "threadRootEventId": thread_root_event_id}},
         )
         return data["followThread"]
 
     async def unfollow_thread(
-        self, space_id: str, room_id: str, thread_root_event_id: str
+        self, room_id: str, thread_root_event_id: str
     ) -> Any:
         data = await self._execute(
             Q.MUTATION_UNFOLLOW_THREAD,
-            {"input": {"spaceId": space_id, "roomId": room_id, "threadRootEventId": thread_root_event_id}},
+            {"input": {"roomId": room_id, "threadRootEventId": thread_root_event_id}},
         )
         return data["unfollowThread"]
 
     async def send_typing_indicator(
-        self, space_id: str, room_id: str, thread_root_event_id: str | None = None
+        self, room_id: str, thread_root_event_id: str | None = None
     ) -> Any:
-        input_data: dict[str, Any] = {"spaceId": space_id, "roomId": room_id}
+        input_data: dict[str, Any] = {"roomId": room_id}
         if thread_root_event_id is not None:
             input_data["threadRootEventId"] = thread_root_event_id
         data = await self._execute(Q.MUTATION_SEND_TYPING, {"input": input_data})

+ 49 - 80
src/chattolib/queries.py

@@ -15,39 +15,29 @@ query Me {
 }
 """
 
-QUERY_SPACES = """
-query Spaces {
-    spaces {
-        id
-        name
-        description
+QUERY_INSTANCE = """
+query Instance {
+    instance {
+        version
+        enabledAuthProviders
+        needsSetup
+        pushNotificationsEnabled
+        livekitUrl
+        directRegistrationEnabled
+        maxUploadSize
+        maxVideoUploadSize
         memberCount
         roomCount
-        viewerIsMember
-    }
-}
-"""
-
-QUERY_SPACE = """
-query Space($id: ID!) {
-    space(id: $id) {
-        id
-        name
-        description
-        logoUrl
-        bannerUrl
-        memberCount
-        roomCount
-        viewerIsMember
+        rooms { id type name description archived autoJoin hasUnread hasMention }
     }
 }
 """
 
 QUERY_ROOM = """
-query Room($spaceId: ID!, $roomId: ID!) {
-    room(spaceId: $spaceId, roomId: $roomId) {
+query Room($roomId: ID!) {
+    room(roomId: $roomId) {
         id
-        spaceId
+        type
         name
         description
         archived
@@ -59,8 +49,8 @@ query Room($spaceId: ID!, $roomId: ID!) {
 """
 
 QUERY_ROOM_EVENTS = """
-query RoomEvents($spaceId: ID!, $roomId: ID!, $limit: Int, $before: Time, $after: Time) {
-    roomEvents(spaceId: $spaceId, roomId: $roomId, limit: $limit, before: $before, after: $after) {
+query RoomEvents($roomId: ID!, $limit: Int, $before: Time, $after: Time) {
+    roomEvents(roomId: $roomId, limit: $limit, before: $before, after: $after) {
         events {
             id
             createdAt
@@ -68,7 +58,6 @@ query RoomEvents($spaceId: ID!, $roomId: ID!, $limit: Int, $before: Time, $after
             actor { id login displayName avatarUrl }
             event {
                 ... on MessagePostedEvent {
-                    spaceId
                     roomId
                     body
                     attachments { id filename contentType size url }
@@ -83,20 +72,21 @@ query RoomEvents($spaceId: ID!, $roomId: ID!, $limit: Int, $before: Time, $after
         }
         hasOlder
         hasNewer
+        startCursor
+        endCursor
     }
 }
 """
 
 QUERY_THREAD_EVENTS = """
-query ThreadEvents($spaceId: ID!, $roomId: ID!, $threadRootEventId: ID!) {
-    threadEvents(spaceId: $spaceId, roomId: $roomId, threadRootEventId: $threadRootEventId) {
+query ThreadEvents($roomId: ID!, $threadRootEventId: ID!) {
+    threadEvents(roomId: $roomId, threadRootEventId: $threadRootEventId) {
         id
         createdAt
         actorId
         actor { id login displayName avatarUrl }
         event {
             ... on MessagePostedEvent {
-                spaceId
                 roomId
                 body
                 attachments { id filename contentType size url }
@@ -154,7 +144,6 @@ query Notifications {
             id
             createdAt
             summary
-            space { id name }
             room { id name }
             eventId
         }
@@ -162,7 +151,6 @@ query Notifications {
             id
             createdAt
             summary
-            space { id name }
             room { id name }
             eventId
         }
@@ -170,7 +158,6 @@ query Notifications {
             id
             createdAt
             summary
-            space { id name }
             room { id name }
             eventId
         }
@@ -185,9 +172,8 @@ query Notifications {
 """
 
 QUERY_FOLLOWED_THREADS = """
-query FollowedThreads($spaceId: ID!) {
-    myFollowedThreads(spaceId: $spaceId) {
-        spaceId
+query FollowedThreads {
+    myFollowedThreads {
         roomId
         threadRootEventId
         replyCount
@@ -234,26 +220,11 @@ mutation RemoveReaction($input: RemoveReactionInput!) {
 }
 """
 
-MUTATION_JOIN_SPACE = """
-mutation JoinSpace($input: JoinSpaceInput!) {
-    joinSpace(input: $input) {
-        id
-        name
-    }
-}
-"""
-
-MUTATION_LEAVE_SPACE = """
-mutation LeaveSpace($input: LeaveSpaceInput!) {
-    leaveSpace(input: $input)
-}
-"""
-
 MUTATION_CREATE_ROOM = """
 mutation CreateRoom($input: CreateRoomInput!) {
     createRoom(input: $input) {
         id
-        spaceId
+        type
         name
         description
     }
@@ -349,31 +320,31 @@ mutation DismissAllNotifications {
 
 # --- Subscriptions ---
 
-SUBSCRIPTION_SPACE_EVENTS = """
-subscription SpaceEvents($spaceId: ID!) {
-    mySpaceEvents(spaceId: $spaceId) {
+SUBSCRIPTION_SERVER_EVENTS = """
+subscription ServerEvents {
+    myServerEvents {
         id
         createdAt
         actorId
         actor { id login displayName avatarUrl }
         event {
-            ... on MessagePostedEvent { spaceId roomId body inThread }
-            ... on MessageUpdatedEvent { spaceId roomId messageEventId }
-            ... on MessageDeletedEvent { spaceId roomId messageEventId }
-            ... on ReactionAddedEvent { spaceId roomId messageEventId emoji }
-            ... on ReactionRemovedEvent { spaceId roomId messageEventId emoji }
-            ... on UserTypingEvent { spaceId roomId threadRootEventId }
+            ... on MessagePostedEvent { roomId body inThread }
+            ... on MessageUpdatedEvent { roomId messageEventId }
+            ... on MessageDeletedEvent { roomId messageEventId }
+            ... on ReactionAddedEvent { roomId messageEventId emoji }
+            ... on ReactionRemovedEvent { roomId messageEventId emoji }
+            ... on UserTypingEvent { roomId threadRootEventId }
             ... on RoomCreatedEvent { roomId name description }
             ... on RoomUpdatedEvent { roomId name description }
             ... on RoomDeletedEvent { roomId }
             ... on RoomArchivedEvent { roomId }
             ... on RoomUnarchivedEvent { roomId }
-            ... on UserJoinedRoomEvent { spaceId roomId }
-            ... on UserLeftRoomEvent { spaceId roomId }
-            ... on SpaceMemberDeletedEvent { spaceId userId }
-            ... on CallParticipantJoinedEvent { spaceId roomId }
-            ... on CallParticipantLeftEvent { spaceId roomId }
-            ... on VideoProcessingCompletedEvent { spaceId roomId attachmentId messageEventId }
+            ... on UserJoinedRoomEvent { roomId }
+            ... on UserLeftRoomEvent { roomId }
+            ... on ServerMemberDeletedEvent { userId }
+            ... on CallParticipantJoinedEvent { roomId }
+            ... on CallParticipantLeftEvent { roomId }
+            ... on VideoProcessingCompletedEvent { roomId attachmentId messageEventId }
             ... on PresenceChangedEvent { status }
         }
     }
@@ -393,20 +364,18 @@ subscription InstanceEvents {
             ... on UserDeletedEvent { userId }
             ... on UserProfileUpdatedEvent { userId displayName avatarUrl login }
             ... on InstanceUserPreferencesUpdatedEvent { timezone timeFormat }
-            ... on NotificationLevelChangedEvent { spaceId roomId level effectiveLevel }
-            ... on UserJoinedSpaceEvent { spaceId }
-            ... on UserLeftSpaceEvent { spaceId }
-            ... on SpaceCreatedEvent { spaceId name description }
-            ... on SpaceUpdatedEvent { spaceId name description }
-            ... on SpaceDeletedEvent { spaceId }
-            ... on MentionNotificationEvent { spaceId roomId }
+            ... on NotificationLevelChangedEvent { roomId level effectiveLevel }
+            ... on UserJoinedServerEvent { userId }
+            ... on UserLeftServerEvent { userId }
+            ... on ServerUpdatedEvent { name description }
+            ... on MentionNotificationEvent { roomId }
             ... on NewDirectMessageNotificationEvent { roomId conversationName }
-            ... on NotificationCreatedEvent { notificationId spaceId roomId eventId inReplyToId }
+            ... on NotificationCreatedEvent { notificationId roomId eventId inReplyToId }
             ... on NotificationDismissedEvent { notificationId }
-            ... on ThreadFollowChangedEvent { spaceId roomId threadRootEventId isFollowing }
-            ... on NewMessageInSpaceEvent { spaceId roomId }
-            ... on RoomMarkedAsReadEvent { spaceId roomId }
-            ... on RoomLayoutUpdatedEvent { spaceId }
+            ... on ThreadFollowChangedEvent { roomId threadRootEventId isFollowing }
+            ... on NewMessageInServerEvent { roomId }
+            ... on RoomMarkedAsReadEvent { roomId }
+            ... on RoomLayoutUpdatedEvent { changed }
             ... on SessionTerminatedEvent { reason }
         }
     }

+ 5 - 7
src/chattolib/subscriptions.py

@@ -58,16 +58,15 @@ async def _keepalive(ws: ClientConnection) -> None:
         pass
 
 
-async def subscribe_space_events(
+async def subscribe_server_events(
     url: str,
     token: str,
-    space_id: str,
     *,
     subscription_id: str = "1",
 ) -> AsyncIterator[dict[str, Any]]:
-    """Subscribe to real-time events for a space.
+    """Subscribe to real-time server events.
 
-    Yields event dicts from the mySpaceEvents subscription.
+    Yields event dicts from the myServerEvents subscription.
     """
     ws = await _connect(url, token)
     ping_task = asyncio.create_task(_keepalive(ws))
@@ -78,8 +77,7 @@ async def subscribe_space_events(
                     "id": subscription_id,
                     "type": _GQL_SUBSCRIBE,
                     "payload": {
-                        "query": Q.SUBSCRIPTION_SPACE_EVENTS,
-                        "variables": {"spaceId": space_id},
+                        "query": Q.SUBSCRIPTION_SERVER_EVENTS,
                     },
                 }
             )
@@ -88,7 +86,7 @@ async def subscribe_space_events(
             msg = json.loads(raw)
             msg_type = msg.get("type")
             if msg_type == _GQL_NEXT:
-                yield msg["payload"]["data"]["mySpaceEvents"]
+                yield msg["payload"]["data"]["myServerEvents"]
             elif msg_type == _GQL_PING:
                 await ws.send(json.dumps({"type": _GQL_PONG}))
             elif msg_type == _GQL_PONG:

+ 8 - 16
src/chattolib/types.py

@@ -30,6 +30,11 @@ class PresenceStatus(str, Enum):
     DO_NOT_DISTURB = "DO_NOT_DISTURB"
 
 
+class RoomType(str, Enum):
+    CHANNEL = "CHANNEL"
+    DM = "DM"
+
+
 class TimeFormat(str, Enum):
     UNSPECIFIED = "UNSPECIFIED"
     TWELVE_HOUR = "TWELVE_HOUR"
@@ -56,23 +61,11 @@ class User:
     presence_status: PresenceStatus | None = None
 
 
-@dataclass
-class Space:
-    id: str
-    name: str
-    description: str | None = None
-    logo_url: str | None = None
-    banner_url: str | None = None
-    member_count: int = 0
-    room_count: int = 0
-    viewer_is_member: bool = False
-
-
 @dataclass
 class Room:
     id: str
-    space_id: str
     name: str
+    type: RoomType | None = None
     description: str | None = None
     archived: bool = False
     auto_join: bool = False
@@ -83,7 +76,6 @@ class Room:
 @dataclass
 class Attachment:
     id: str
-    space_id: str
     room_id: str
     filename: str
     content_type: str
@@ -117,7 +109,6 @@ class MessageEvent:
     """A message event from roomEvents / threadEvents."""
 
     id: str
-    space_id: str
     room_id: str
     body: str | None = None
     created_at: datetime | None = None
@@ -135,11 +126,12 @@ class RoomEventsPage:
     events: list[MessageEvent]
     has_older: bool = False
     has_newer: bool = False
+    start_cursor: str | None = None
+    end_cursor: str | None = None
 
 
 @dataclass
 class FollowedThread:
-    space_id: str
     room_id: str
     thread_root_event_id: str
     reply_count: int = 0

+ 29 - 24
tests/test_client.py

@@ -46,28 +46,32 @@ async def test_me(mock_api, client):
     assert user.display_name == "Alice"
 
 
-async def test_spaces(mock_api, client):
+async def test_rooms(mock_api, client):
     mock_api.post("/api/graphql").mock(
         return_value=_gql_response(
             {
-                "spaces": [
-                    {
-                        "id": "s1",
-                        "name": "General",
-                        "description": None,
-                        "memberCount": 5,
-                        "roomCount": 3,
-                        "viewerIsMember": True,
-                    }
-                ]
+                "instance": {
+                    "rooms": [
+                        {
+                            "id": "r1",
+                            "type": "CHANNEL",
+                            "name": "general",
+                            "description": None,
+                            "archived": False,
+                            "autoJoin": True,
+                            "hasUnread": False,
+                            "hasMention": False,
+                        }
+                    ]
+                }
             }
         )
     )
     async with client:
-        spaces = await client.spaces()
-    assert len(spaces) == 1
-    assert spaces[0].name == "General"
-    assert spaces[0].viewer_is_member is True
+        rooms = await client.rooms()
+    assert len(rooms) == 1
+    assert rooms[0].name == "general"
+    assert rooms[0].type.value == "CHANNEL"
 
 
 async def test_graphql_error(mock_api, client):
@@ -96,7 +100,7 @@ async def test_post_message(mock_api, client):
         )
     )
     async with client:
-        result = await client.post_message("s1", "r1", "Hello!")
+        result = await client.post_message("r1", "Hello!")
     assert result["id"] == "e1"
 
 
@@ -117,7 +121,6 @@ async def test_room_events(mock_api, client):
                                 "avatarUrl": None,
                             },
                             "event": {
-                                "spaceId": "s1",
                                 "roomId": "r1",
                                 "body": "Hello",
                                 "attachments": [],
@@ -132,12 +135,14 @@ async def test_room_events(mock_api, client):
                     ],
                     "hasOlder": False,
                     "hasNewer": False,
+                    "startCursor": None,
+                    "endCursor": None,
                 }
             }
         )
     )
     async with client:
-        page = await client.room_events("s1", "r1", limit=10)
+        page = await client.room_events("r1", limit=10)
     assert len(page.events) == 1
     assert page.events[0].body == "Hello"
     assert page.events[0].actor.login == "alice"
@@ -170,7 +175,7 @@ async def test_edit_message(mock_api, client):
         return_value=_gql_response({"editMessage": {"id": "e1"}})
     )
     async with client:
-        result = await client.edit_message("s1", "r1", "e1", "edited body")
+        result = await client.edit_message("r1", "e1", "edited body")
     assert result["id"] == "e1"
 
 
@@ -179,7 +184,7 @@ async def test_delete_message(mock_api, client):
         return_value=_gql_response({"deleteMessage": True})
     )
     async with client:
-        result = await client.delete_message("s1", "r1", "e1")
+        result = await client.delete_message("r1", "e1")
     assert result is True
 
 
@@ -188,7 +193,7 @@ async def test_add_reaction(mock_api, client):
         return_value=_gql_response({"addReaction": True})
     )
     async with client:
-        result = await client.add_reaction("s1", "r1", "e1", "👍")
+        result = await client.add_reaction("r1", "e1", "thumbsup")
     assert result is True
 
 
@@ -197,18 +202,18 @@ async def test_remove_reaction(mock_api, client):
         return_value=_gql_response({"removeReaction": True})
     )
     async with client:
-        result = await client.remove_reaction("s1", "r1", "e1", "👍")
+        result = await client.remove_reaction("r1", "e1", "thumbsup")
     assert result is True
 
 
 async def test_create_room(mock_api, client):
     mock_api.post("/api/graphql").mock(
         return_value=_gql_response(
-            {"createRoom": {"id": "r1", "spaceId": "s1", "name": "general", "description": None}}
+            {"createRoom": {"id": "r1", "type": "CHANNEL", "name": "general", "description": None}}
         )
     )
     async with client:
-        room = await client.create_room("s1", "general")
+        room = await client.create_room("general")
     assert room.id == "r1"
     assert room.name == "general"
 

+ 6 - 6
tests/test_integration.py

@@ -40,9 +40,9 @@ async def test_invalid_login():
         await ChattoClient.login("nonexistent_user_xyz", "wrongpassword")
 
 
-async def test_list_spaces(client):
-    spaces = await client.spaces()
-    assert isinstance(spaces, list)
-    print(f"Found {len(spaces)} spaces")
-    for s in spaces:
-        print(f"  - {s.name} (id={s.id}, members={s.member_count})")
+async def test_list_rooms(client):
+    rooms = await client.rooms()
+    assert isinstance(rooms, list)
+    print(f"Found {len(rooms)} rooms")
+    for r in rooms:
+        print(f"  - {r.name} (id={r.id}, type={r.type})")