Forráskód Böngészése

Initial commit: chattolib + RoboChatto bot

Async Python client library for the Chatto webchat GraphQL API with
full query/mutation/subscription support, plus a command bot (RoboChatto)
with tier-based permissions, SQLite logging, and Ollama LLM integration.
TheCodemancer 6 hónapja
commit
3aa8c9c151

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+__pycache__/
+*.egg-info/
+.venv/
+.pytest_cache/
+robochatto.env
+robochatto.db

+ 71 - 0
CLAUDE.md

@@ -0,0 +1,71 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 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.
+
+## Build & Development
+
+```bash
+# Install in development mode
+pip install -e ".[dev]"
+
+# Run all tests
+pytest
+
+# Run a single test
+pytest tests/test_foo.py::test_bar -v
+
+# Lint & format
+ruff check .
+ruff format .
+
+# Type checking
+mypy src/chattolib
+```
+
+## Architecture
+
+The library uses **httpx** for async HTTP and **websockets** for GraphQL subscriptions (real-time events via WebSocket).
+
+### 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.
+- **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.).
+- **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)` | `createSpace`, `updateSpace`, `joinSpace`, `leaveSpace`, logo/banner uploads | `mySpaceEvents` |
+| **Rooms** | `room`, `roomEvents`, `roomEventByEventId`, `threadEvents`, `roomEventsAround` | `createRoom`, `updateRoom`, `archiveRoom`, `joinRoom`, `leaveRoom`, `markRoomAsRead` | via space events |
+| **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`, `createUser` | `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 | — |
+| **Voice calls** | `voiceCallToken`, `activeCallRoomIds`, `callParticipants` | — | `CallParticipantJoinedEvent`, `CallParticipantLeftEvent` |
+| **Admin** | `admin.systemInfo`, `admin.instanceConfig`, `admin.roles` | `admin.updateInstanceConfig`, `admin.resetInstanceConfig` | `myInstanceEvents` |
+
+### GraphQL conventions
+
+- 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` cursor style on room events; `limit`/`offset` on space members.
+- All mutations take a single `input` argument with a corresponding `*Input` type.
+- Subscriptions use `spaceId` scoping (`mySpaceEvents`) or are instance-wide (`myInstanceEvents`).
+- Image URLs accept optional `width`, `height`, `fit` (enum: `CONTAIN`, `COVER`, `EXACT`) for server-side resizing.
+
+### Naming conventions
+
+- Python field/method names: `snake_case` (translated from GraphQL `camelCase`)
+- Type classes: `PascalCase` matching the GraphQL type names
+- Query/mutation method names on the client: `verb_noun` style (e.g., `create_room`, `post_message`, `mark_room_as_read`)

+ 295 - 0
bot.py

@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+"""RoboChatto — a simple command bot for Chatto."""
+
+import asyncio
+import os
+import sqlite3
+import sys
+import time
+from datetime import datetime, timezone
+
+import httpx
+
+from chattolib.client import ChattoClient
+from chattolib.subscriptions import subscribe_space_events
+from chattolib.types import PresenceStatus
+
+START_TIME = time.monotonic()
+DB_PATH = os.environ.get("ROBOCHATTO_DB", "robochatto.db")
+OLLAMA_URL = os.environ.get("OLLAMA_URL", "https://ollama.f3l1x.it")
+OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5")
+
+COMMANDS = {
+    "/rc:help": "This is a bot created by Felix (and Claude). Use `/rc:commands` to see available commands.",
+    "/rc:shrug": "¯\\_(ツ)_/¯",
+}
+
+
+def init_db() -> sqlite3.Connection:
+    db = sqlite3.connect(DB_PATH)
+    db.execute("""
+        CREATE TABLE IF NOT EXISTS command_log (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            timestamp TEXT NOT NULL,
+            user_id TEXT NOT NULL,
+            user_login TEXT NOT NULL,
+            space_id TEXT NOT NULL,
+            room_id TEXT NOT NULL,
+            command TEXT NOT NULL,
+            response TEXT NOT NULL
+        )
+    """)
+    db.execute("""
+        CREATE TABLE IF NOT EXISTS tiers (
+            name TEXT PRIMARY KEY,
+            level INTEGER NOT NULL UNIQUE
+        )
+    """)
+    db.execute("""
+        CREATE TABLE IF NOT EXISTS user_tiers (
+            user_login TEXT PRIMARY KEY,
+            tier TEXT NOT NULL REFERENCES tiers(name)
+        )
+    """)
+    db.execute("""
+        CREATE TABLE IF NOT EXISTS command_tiers (
+            command TEXT PRIMARY KEY,
+            tier TEXT NOT NULL REFERENCES tiers(name)
+        )
+    """)
+    # Seed default tiers if empty
+    if db.execute("SELECT COUNT(*) FROM tiers").fetchone()[0] == 0:
+        db.executemany(
+            "INSERT INTO tiers (name, level) VALUES (?, ?)",
+            [("admin", 0), ("premium", 1), ("basic", 2), ("bot", 3)],
+        )
+    # Seed default user tiers if empty
+    if db.execute("SELECT COUNT(*) FROM user_tiers").fetchone()[0] == 0:
+        db.executemany(
+            "INSERT OR IGNORE INTO user_tiers (user_login, tier) VALUES (?, ?)",
+            [("felix", "admin"), ("RoboChatto", "bot"), ("chattobot", "bot")],
+        )
+    # Seed default command tiers if empty
+    if db.execute("SELECT COUNT(*) FROM command_tiers").fetchone()[0] == 0:
+        db.executemany(
+            "INSERT OR IGNORE INTO command_tiers (command, tier) VALUES (?, ?)",
+            [("/rc:commands", "basic"), ("/rc:help", "basic"), ("/rc:shrug", "basic"), ("/rc:uptime", "basic"), ("/rc:prompt", "premium")],
+        )
+    db.commit()
+    return db
+
+
+def check_permission(db: sqlite3.Connection, command: str, user_login: str) -> str | None:
+    """Check if a user's tier level is <= the command's required tier level.
+
+    Returns None if allowed, or the required tier name if denied.
+    No command_tiers row = open to all. No user_tiers row = treated as basic.
+    """
+    row = db.execute(
+        "SELECT t.level, t.name FROM command_tiers ct JOIN tiers t ON t.name = ct.tier WHERE ct.command = ?",
+        (command,),
+    ).fetchone()
+    if row is None:
+        return None  # no restriction on this command
+    required_level, required_tier = row
+
+    row = db.execute(
+        "SELECT t.level FROM user_tiers ut JOIN tiers t ON t.name = ut.tier WHERE ut.user_login = ?",
+        (user_login,),
+    ).fetchone()
+    if row is None:
+        user_level = db.execute("SELECT level FROM tiers WHERE name = 'basic'").fetchone()[0]
+    else:
+        user_level = row[0]
+
+    if user_level <= required_level:
+        return None
+    return required_tier
+
+
+def log_command(
+    db: sqlite3.Connection,
+    *,
+    user_id: str,
+    user_login: str,
+    space_id: str,
+    room_id: str,
+    command: str,
+    response: str,
+) -> None:
+    db.execute(
+        "INSERT INTO command_log (timestamp, user_id, user_login, space_id, room_id, command, response) VALUES (?, ?, ?, ?, ?, ?, ?)",
+        (datetime.now(timezone.utc).isoformat(), user_id, user_login, space_id, room_id, command, response),
+    )
+    db.commit()
+
+
+async def query_ollama(prompt: str) -> str:
+    """Send a prompt to the Ollama-compatible API and return the response."""
+    async with httpx.AsyncClient(timeout=120) as http:
+        resp = await http.post(
+            f"{OLLAMA_URL}/v1/chat/completions",
+            json={
+                "model": OLLAMA_MODEL,
+                "messages": [
+                    {"role": "system", "content": "Do not use markdown headlines (lines starting with #). Use **bold** for emphasis instead."},
+                    {"role": "user", "content": prompt},
+                ],
+                "stream": False,
+            },
+        )
+        resp.raise_for_status()
+        return resp.json()["choices"][0]["message"]["content"]
+
+
+async def handle_event(client: ChattoClient, db: sqlite3.Connection, event: dict) -> None:
+    inner = event.get("event", {})
+    body = inner.get("body", "")
+    if not body:
+        return
+
+    cmd = body.strip()
+    # Extract the command name (first word) for prefix commands
+    cmd_name = cmd.split()[0] if cmd.split() else cmd
+
+    space_id = inner["spaceId"]
+    room_id = inner["roomId"]
+    event_id = event.get("id")
+    in_thread = inner.get("inThread")
+    actor = event.get("actor", {})
+    user_id = actor.get("id", "?")
+    user_login = actor.get("login", "?")
+
+    if cmd_name == "/rc:commands":
+        rows = db.execute(
+            "SELECT ct.command, ct.tier FROM command_tiers ct ORDER BY ct.command"
+        ).fetchall()
+        lines = ["**Available commands:**"]
+        for command, tier in rows:
+            lines.append(f"- `{command}` — {tier}+")
+        response = "\n".join(lines)
+    elif cmd_name == "/rc:uptime":
+        elapsed = int(time.monotonic() - START_TIME)
+        days, rem = divmod(elapsed, 86400)
+        hours, rem = divmod(rem, 3600)
+        minutes, seconds = divmod(rem, 60)
+        parts = []
+        if days:
+            parts.append(f"{days}d")
+        if hours:
+            parts.append(f"{hours}h")
+        if minutes:
+            parts.append(f"{minutes}m")
+        parts.append(f"{seconds}s")
+        response = f"Uptime: {' '.join(parts)}"
+    elif cmd_name == "/rc:prompt":
+        prompt_text = cmd[len("/rc:prompt"):].strip()
+        if not prompt_text:
+            response = "Usage: /rc:prompt <your question>"
+        else:
+            await client.add_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
+            try:
+                response = await query_ollama(prompt_text)
+            except Exception as e:
+                print(f"[{user_login}] {cmd_name} → error: {e}", flush=True)
+                response = "Something went wrong."
+            finally:
+                try:
+                    await client.remove_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
+                except Exception:
+                    pass
+    else:
+        response = COMMANDS.get(cmd)
+    if response is None:
+        return
+
+    denied_tier = check_permission(db, cmd_name, user_login)
+    if denied_tier is not None:
+        print(f"[{user_login}] {cmd_name} → denied (requires {denied_tier} tier)")
+        await client.post_message(
+            space_id, room_id,
+            f"Access denied. `{cmd_name}` requires **{denied_tier}** tier or above.",
+            in_reply_to=event_id,
+            in_thread=in_thread,
+        )
+        return
+
+    print(f"[{user_login}] {cmd} → replying in {room_id}")
+    await client.post_message(space_id, room_id, response, in_reply_to=event_id, in_thread=in_thread)
+
+    log_command(
+        db,
+        user_id=user_id,
+        user_login=user_login,
+        space_id=space_id,
+        room_id=room_id,
+        command=cmd,
+        response=response,
+    )
+
+
+async def main() -> None:
+    login = os.environ.get("CHATTO_LOGIN")
+    password = os.environ.get("CHATTO_PASSWORD")
+    if not login or not password:
+        print("Set CHATTO_LOGIN and CHATTO_PASSWORD environment variables", file=sys.stderr)
+        sys.exit(1)
+
+    db = init_db()
+    print(f"Database: {DB_PATH}")
+
+    client = await ChattoClient.login(login, password)
+    async with client:
+        me = await client.me()
+        print(f"Logged in as {me.login} (id={me.id})")
+
+        await client.update_presence(PresenceStatus.ONLINE)
+        print("Presence set to ONLINE")
+
+        spaces = await client.spaces()
+        if not spaces:
+            print("No spaces found", file=sys.stderr)
+            sys.exit(1)
+
+        print(f"Listening on {len(spaces)} space(s):")
+        for s in spaces:
+            print(f"  - {s.name} ({s.id})")
+
+        # Subscribe to all spaces + keep presence alive
+        try:
+            async with asyncio.TaskGroup() as tg:
+                tg.create_task(presence_keepalive(client))
+                for space in spaces:
+                    tg.create_task(listen_space(client, db, space.id, space.name))
+        finally:
+            print("Shutting down (disconnecting sets presence to offline)")
+            db.close()
+
+
+async def presence_keepalive(client: ChattoClient) -> None:
+    """Re-send ONLINE presence every 60s to stay visible in the user list."""
+    while True:
+        try:
+            await asyncio.sleep(60)
+            await client.update_presence(PresenceStatus.ONLINE)
+        except Exception as e:
+            print(f"[presence] Error: {e}")
+            await asyncio.sleep(10)
+
+
+async def listen_space(
+    client: ChattoClient, db: sqlite3.Connection, space_id: str, space_name: str
+) -> None:
+    while True:
+        try:
+            async for event in subscribe_space_events(
+                client._url, client._token, space_id
+            ):
+                await handle_event(client, db, event)
+        except Exception as e:
+            print(f"[{space_name}] Connection lost: {e}. Reconnecting in 5s...")
+            await asyncio.sleep(5)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

BIN
image.jpg


+ 43 - 0
pyproject.toml

@@ -0,0 +1,43 @@
+[build-system]
+requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "chattolib"
+version = "0.1.0"
+description = "Async Python client library for the Chatto webchat GraphQL API"
+requires-python = ">=3.11"
+license = "MIT"
+dependencies = [
+    "httpx>=0.27",
+    "websockets>=13.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8.0",
+    "pytest-asyncio>=0.24",
+    "respx>=0.22",
+    "ruff>=0.8",
+    "mypy>=1.13",
+]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "N", "W", "UP"]
+
+[tool.mypy]
+python_version = "3.11"
+strict = true
+warn_return_any = true
+warn_unused_configs = true

+ 11 - 0
src/chattolib/__init__.py

@@ -0,0 +1,11 @@
+"""Async Python client library for the Chatto webchat GraphQL API."""
+
+from chattolib.client import ChattoClient
+from chattolib.exceptions import ChattoError, ChattoGraphQLError, ChattoAuthError
+
+__all__ = [
+    "ChattoClient",
+    "ChattoError",
+    "ChattoGraphQLError",
+    "ChattoAuthError",
+]

+ 532 - 0
src/chattolib/client.py

@@ -0,0 +1,532 @@
+"""Main async client for the Chatto GraphQL API."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, AsyncIterator
+
+import httpx
+
+from chattolib import queries as Q
+from chattolib.exceptions import ChattoAuthError, ChattoGraphQLError
+from chattolib.types import (
+    Attachment,
+    FollowedThread,
+    LinkPreview,
+    MessageEvent,
+    PresenceStatus,
+    Reaction,
+    Room,
+    RoomEventsPage,
+    Space,
+    User,
+)
+
+
+def _parse_datetime(value: str | None) -> datetime | None:
+    if value is None:
+        return None
+    return datetime.fromisoformat(value)
+
+
+def _parse_user(data: dict[str, Any]) -> User:
+    return User(
+        id=data["id"],
+        login=data["login"],
+        display_name=data["displayName"],
+        created_at=_parse_datetime(data.get("createdAt")),
+        avatar_url=data.get("avatarUrl"),
+        presence_status=PresenceStatus(data["presenceStatus"]) if data.get("presenceStatus") else None,
+    )
+
+
+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"],
+        description=data.get("description"),
+        archived=data.get("archived", False),
+        auto_join=data.get("autoJoin", False),
+        has_unread=data.get("hasUnread", False),
+        has_mention=data.get("hasMention", False),
+    )
+
+
+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"],
+        size=data["size"],
+        url=data.get("url"),
+        width=data.get("width"),
+        height=data.get("height"),
+    )
+
+
+def _parse_reaction(data: dict[str, Any]) -> Reaction:
+    return Reaction(
+        emoji=data["emoji"],
+        count=data["count"],
+        has_reacted=data.get("hasReacted", False),
+        users=[_parse_user(u) for u in data.get("users", [])],
+    )
+
+
+def _parse_message_event(data: dict[str, Any]) -> MessageEvent:
+    event = data.get("event", {})
+    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")),
+        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"),
+        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"),
+    )
+
+
+class ChattoClient:
+    """Async client for the Chatto GraphQL API.
+
+    Usage::
+
+        async with ChattoClient.login("user", "pass") as client:
+            me = await client.me()
+            spaces = await client.spaces()
+
+        # Or with a token directly:
+        async with ChattoClient(token="cht_...") as client:
+            ...
+    """
+
+    DEFAULT_BASE_URL = "https://chat.chatto.run"
+
+    def __init__(
+        self,
+        token: str | None = None,
+        *,
+        base_url: str = DEFAULT_BASE_URL,
+        session_cookie: str | None = None,
+        httpx_client: httpx.AsyncClient | None = None,
+    ) -> None:
+        self._base_url = base_url.rstrip("/")
+        self._url = f"{self._base_url}/api/graphql"
+        self._token = token
+        self._session_cookie = session_cookie
+        self._owns_client = httpx_client is None
+        self._http = httpx_client or httpx.AsyncClient()
+
+    @classmethod
+    async def login(
+        cls,
+        login: str,
+        password: str,
+        *,
+        base_url: str = DEFAULT_BASE_URL,
+    ) -> ChattoClient:
+        """Authenticate with username and password, returning a connected client."""
+        base = base_url.rstrip("/")
+        async with httpx.AsyncClient() as http:
+            resp = await http.post(
+                f"{base}/auth/login",
+                json={"login": login, "password": password},
+            )
+            if resp.status_code == 401:
+                raise ChattoAuthError("Invalid credentials")
+            resp.raise_for_status()
+            body = resp.json()
+
+        token = body.get("token")
+        session_cookie = None
+        if "set-cookie" in resp.headers:
+            for cookie_header in resp.headers.get_list("set-cookie"):
+                if cookie_header.startswith("chatto_session="):
+                    session_cookie = cookie_header.split(";")[0].split("=", 1)[1]
+                    break
+
+        return cls(token=token, base_url=base_url, session_cookie=session_cookie)
+
+    async def __aenter__(self) -> ChattoClient:
+        return self
+
+    async def __aexit__(self, *exc: Any) -> None:
+        await self.close()
+
+    async def close(self) -> None:
+        if self._owns_client:
+            await self._http.aclose()
+
+    # --- Transport ---
+
+    async def _execute(
+        self,
+        query: str,
+        variables: dict[str, Any] | None = None,
+    ) -> dict[str, Any]:
+        """Execute a GraphQL query/mutation and return the data dict."""
+        headers: dict[str, str] = {}
+        if self._token:
+            headers["Authorization"] = f"Bearer {self._token}"
+        if self._session_cookie:
+            headers["Cookie"] = f"chatto_session={self._session_cookie}"
+
+        payload: dict[str, Any] = {"query": query}
+        if variables:
+            payload["variables"] = variables
+
+        response = await self._http.post(self._url, json=payload, headers=headers)
+
+        if response.status_code == 401:
+            raise ChattoAuthError("Authentication failed")
+
+        # Chatto returns 422 with a JSON body for GraphQL validation errors
+        body = response.json()
+
+        if "errors" in body:
+            raise ChattoGraphQLError(body["errors"], data=body.get("data"))
+
+        response.raise_for_status()
+
+        return body["data"]
+
+    async def _execute_upload(
+        self,
+        query: str,
+        variables: dict[str, Any],
+        file_path: str,
+        variable_path: str = "input.file",
+    ) -> dict[str, Any]:
+        """Execute a GraphQL mutation with a file upload (multipart request spec)."""
+        import json as _json
+        from pathlib import Path
+
+        headers: dict[str, str] = {}
+        if self._token:
+            headers["Authorization"] = f"Bearer {self._token}"
+        if self._session_cookie:
+            headers["Cookie"] = f"chatto_session={self._session_cookie}"
+
+        p = Path(file_path)
+        operations = _json.dumps({"query": query, "variables": variables})
+        map_field = _json.dumps({"0": [f"variables.{variable_path}"]})
+
+        response = await self._http.post(
+            self._url,
+            headers=headers,
+            data={"operations": operations, "map": map_field},
+            files={"0": (p.name, p.read_bytes(), "application/octet-stream")},
+        )
+
+        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"))
+
+        return body["data"]
+
+    # --- Queries ---
+
+    async def me(self) -> User:
+        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 space(self, space_id: str) -> Space:
+        data = await self._execute(Q.QUERY_SPACE, {"id": space_id})
+        return _parse_space(data["space"])
+
+    async def room(self, space_id: str, room_id: str) -> Room:
+        data = await self._execute(Q.QUERY_ROOM, {"spaceId": space_id, "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}
+        if limit is not None:
+            variables["limit"] = limit
+        if before is not None:
+            variables["before"] = before
+        if after is not None:
+            variables["after"] = after
+        data = await self._execute(Q.QUERY_ROOM_EVENTS, variables)
+        conn = data["roomEvents"]
+        return RoomEventsPage(
+            events=[_parse_message_event(e) for e in conn["events"]],
+            has_older=conn["hasOlder"],
+            has_newer=conn["hasNewer"],
+        )
+
+    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},
+        )
+        return [_parse_message_event(e) for e in data["threadEvents"]]
+
+    async def user(self, user_id: str) -> User:
+        data = await self._execute(Q.QUERY_USER, {"id": user_id})
+        return _parse_user(data["user"])
+
+    async def user_by_login(self, login: str) -> User:
+        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 notifications(self) -> list[dict[str, Any]]:
+        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})
+        return [
+            FollowedThread(
+                space_id=t["spaceId"],
+                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["myFollowedThreads"]
+        ]
+
+    # --- Mutations ---
+
+    async def create_user(self, login: str, display_name: str, password: str) -> User:
+        data = await self._execute(
+            Q.MUTATION_CREATE_USER,
+            {"input": {"login": login, "displayName": display_name, "password": password}},
+        )
+        return _parse_user(data["createUser"])
+
+    async def post_message(
+        self,
+        space_id: str,
+        room_id: str,
+        body: str,
+        *,
+        in_thread: str | None = None,
+        in_reply_to: str | None = None,
+        also_send_to_channel: bool | None = None,
+    ) -> dict[str, Any]:
+        input_data: dict[str, Any] = {
+            "spaceId": space_id,
+            "roomId": room_id,
+            "body": body,
+        }
+        if in_thread is not None:
+            input_data["inThread"] = in_thread
+        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
+        data = await self._execute(Q.MUTATION_POST_MESSAGE, {"input": input_data})
+        return data["postMessage"]
+
+    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}},
+        )
+        return data["editMessage"]
+
+    async def delete_message(self, space_id: str, 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}},
+        )
+        return data["deleteMessage"]
+
+    async def add_reaction(
+        self, space_id: str, 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}},
+        )
+        return data["addReaction"]
+
+    async def remove_reaction(
+        self, space_id: str, 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}},
+        )
+        return data["removeReaction"]
+
+    async def create_space(self, name: str, description: str | None = None) -> Space:
+        input_data: dict[str, Any] = {"name": name}
+        if description is not None:
+            input_data["description"] = description
+        data = await self._execute(Q.MUTATION_CREATE_SPACE, {"input": input_data})
+        return _parse_space(data["createSpace"])
+
+    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
+    ) -> Room:
+        input_data: dict[str, Any] = {"spaceId": space_id, "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]:
+        data = await self._execute(
+            Q.MUTATION_JOIN_ROOM, {"input": {"spaceId": space_id, "roomId": room_id}}
+        )
+        return data["joinRoom"]
+
+    async def leave_room(self, space_id: str, room_id: str) -> Any:
+        data = await self._execute(
+            Q.MUTATION_LEAVE_ROOM, {"input": {"spaceId": space_id, "roomId": room_id}}
+        )
+        return data["leaveRoom"]
+
+    async def mark_room_as_read(self, space_id: str, room_id: str) -> dict[str, Any]:
+        data = await self._execute(
+            Q.MUTATION_MARK_ROOM_AS_READ, {"input": {"spaceId": space_id, "roomId": room_id}}
+        )
+        return data["markRoomAsRead"]
+
+    async def follow_thread(
+        self, space_id: str, 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}},
+        )
+        return data["followThread"]
+
+    async def unfollow_thread(
+        self, space_id: str, 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}},
+        )
+        return data["unfollowThread"]
+
+    async def send_typing_indicator(
+        self, space_id: str, room_id: str, thread_root_event_id: str | None = None
+    ) -> Any:
+        input_data: dict[str, Any] = {"spaceId": space_id, "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})
+        return data["sendTypingIndicator"]
+
+    async def start_dm(self, participant_ids: list[str]) -> dict[str, Any]:
+        data = await self._execute(
+            Q.MUTATION_START_DM, {"input": {"participantIds": participant_ids}}
+        )
+        return data["startDM"]
+
+    async def update_my_profile(
+        self,
+        *,
+        display_name: str | None = None,
+        login: str | None = None,
+    ) -> User:
+        input_data: dict[str, Any] = {}
+        if display_name is not None:
+            input_data["displayName"] = display_name
+        if login is not None:
+            input_data["login"] = login
+        data = await self._execute(Q.MUTATION_UPDATE_MY_PROFILE, {"input": input_data})
+        return _parse_user(data["updateMyProfile"])
+
+    async def upload_my_avatar(self, file_path: str) -> dict[str, Any]:
+        data = await self._execute_upload(
+            Q.MUTATION_UPLOAD_MY_AVATAR,
+            {"input": {"file": None}},
+            file_path,
+        )
+        return data["uploadMyAvatar"]
+
+    async def update_presence(self, status: PresenceStatus) -> Any:
+        data = await self._execute(
+            Q.MUTATION_UPDATE_PRESENCE, {"input": {"status": status.value}}
+        )
+        return data["updateMyPresence"]
+
+    async def dismiss_notification(self, notification_id: str) -> Any:
+        data = await self._execute(
+            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"]

+ 23 - 0
src/chattolib/exceptions.py

@@ -0,0 +1,23 @@
+"""Exception hierarchy for chattolib."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+class ChattoError(Exception):
+    """Base exception for all chattolib errors."""
+
+
+class ChattoGraphQLError(ChattoError):
+    """One or more GraphQL errors were returned by the API."""
+
+    def __init__(self, errors: list[dict[str, Any]], data: Any = None) -> None:
+        self.errors = errors
+        self.data = data
+        messages = "; ".join(e.get("message", str(e)) for e in errors)
+        super().__init__(messages)
+
+
+class ChattoAuthError(ChattoError):
+    """Authentication failed (missing token, expired session, etc.)."""

+ 437 - 0
src/chattolib/queries.py

@@ -0,0 +1,437 @@
+"""GraphQL query, mutation, and subscription strings."""
+
+# --- Queries ---
+
+QUERY_ME = """
+query Me {
+    me {
+        id
+        login
+        displayName
+        createdAt
+        avatarUrl
+        presenceStatus
+    }
+}
+"""
+
+QUERY_SPACES = """
+query Spaces {
+    spaces {
+        id
+        name
+        description
+        memberCount
+        roomCount
+        viewerIsMember
+    }
+}
+"""
+
+QUERY_SPACE = """
+query Space($id: ID!) {
+    space(id: $id) {
+        id
+        name
+        description
+        logoUrl
+        bannerUrl
+        memberCount
+        roomCount
+        viewerIsMember
+    }
+}
+"""
+
+QUERY_ROOM = """
+query Room($spaceId: ID!, $roomId: ID!) {
+    room(spaceId: $spaceId, roomId: $roomId) {
+        id
+        spaceId
+        name
+        description
+        archived
+        autoJoin
+        hasUnread
+        hasMention
+    }
+}
+"""
+
+QUERY_ROOM_EVENTS = """
+query RoomEvents($spaceId: ID!, $roomId: ID!, $limit: Int, $before: ID, $after: ID) {
+    roomEvents(spaceId: $spaceId, roomId: $roomId, limit: $limit, before: $before, after: $after) {
+        events {
+            id
+            createdAt
+            actorId
+            actor { id login displayName avatarUrl }
+            event {
+                ... on MessagePostedEvent {
+                    spaceId
+                    roomId
+                    body
+                    attachments { id filename contentType size url }
+                    reactions { emoji count hasReacted users { id login displayName } }
+                    inReplyTo
+                    inThread
+                    replyCount
+                    lastReplyAt
+                    linkPreview { url title description siteName }
+                }
+            }
+        }
+        hasOlder
+        hasNewer
+    }
+}
+"""
+
+QUERY_THREAD_EVENTS = """
+query ThreadEvents($spaceId: ID!, $roomId: ID!, $threadRootEventId: ID!) {
+    threadEvents(spaceId: $spaceId, roomId: $roomId, threadRootEventId: $threadRootEventId) {
+        id
+        createdAt
+        actorId
+        actor { id login displayName avatarUrl }
+        event {
+            ... on MessagePostedEvent {
+                spaceId
+                roomId
+                body
+                attachments { id filename contentType size url }
+                reactions { emoji count hasReacted }
+                inReplyTo
+                inThread
+            }
+        }
+    }
+}
+"""
+
+QUERY_USER = """
+query User($id: ID!) {
+    user(id: $id) {
+        id
+        login
+        displayName
+        createdAt
+        avatarUrl
+        presenceStatus
+    }
+}
+"""
+
+QUERY_USER_BY_LOGIN = """
+query UserByLogin($login: String!) {
+    userByLogin(login: $login) {
+        id
+        login
+        displayName
+        createdAt
+        avatarUrl
+        presenceStatus
+    }
+}
+"""
+
+QUERY_USERS = """
+query Users {
+    users {
+        id
+        login
+        displayName
+        avatarUrl
+        presenceStatus
+    }
+}
+"""
+
+QUERY_NOTIFICATIONS = """
+query Notifications {
+    notifications {
+        ... on MentionNotificationItem {
+            id
+            createdAt
+            summary
+            space { id name }
+            room { id name }
+            eventId
+        }
+        ... on ReplyNotificationItem {
+            id
+            createdAt
+            summary
+            space { id name }
+            room { id name }
+            eventId
+        }
+        ... on RoomMessageNotificationItem {
+            id
+            createdAt
+            summary
+            space { id name }
+            room { id name }
+            eventId
+        }
+        ... on DMMessageNotificationItem {
+            id
+            createdAt
+            summary
+            room { id name }
+        }
+    }
+}
+"""
+
+QUERY_FOLLOWED_THREADS = """
+query FollowedThreads($spaceId: ID!) {
+    myFollowedThreads(spaceId: $spaceId) {
+        spaceId
+        roomId
+        threadRootEventId
+        replyCount
+        lastReplyAt
+        hasUnread
+    }
+}
+"""
+
+# --- Mutations ---
+
+MUTATION_CREATE_USER = """
+mutation CreateUser($input: CreateUserInput!) {
+    createUser(input: $input) {
+        id
+        login
+        displayName
+        createdAt
+        avatarUrl
+        presenceStatus
+    }
+}
+"""
+
+MUTATION_POST_MESSAGE = """
+mutation PostMessage($input: PostMessageInput!) {
+    postMessage(input: $input) {
+        id
+        createdAt
+    }
+}
+"""
+
+MUTATION_EDIT_MESSAGE = """
+mutation EditMessage($input: EditMessageInput!) {
+    editMessage(input: $input) {
+        id
+    }
+}
+"""
+
+MUTATION_DELETE_MESSAGE = """
+mutation DeleteMessage($input: DeleteMessageInput!) {
+    deleteMessage(input: $input)
+}
+"""
+
+MUTATION_ADD_REACTION = """
+mutation AddReaction($input: AddReactionInput!) {
+    addReaction(input: $input)
+}
+"""
+
+MUTATION_REMOVE_REACTION = """
+mutation RemoveReaction($input: RemoveReactionInput!) {
+    removeReaction(input: $input)
+}
+"""
+
+MUTATION_CREATE_SPACE = """
+mutation CreateSpace($input: CreateSpaceInput!) {
+    createSpace(input: $input) {
+        id
+        name
+        description
+    }
+}
+"""
+
+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
+        name
+        description
+    }
+}
+"""
+
+MUTATION_JOIN_ROOM = """
+mutation JoinRoom($input: JoinRoomInput!) {
+    joinRoom(input: $input) {
+        id
+        name
+    }
+}
+"""
+
+MUTATION_LEAVE_ROOM = """
+mutation LeaveRoom($input: LeaveRoomInput!) {
+    leaveRoom(input: $input)
+}
+"""
+
+MUTATION_MARK_ROOM_AS_READ = """
+mutation MarkRoomAsRead($input: MarkRoomAsReadInput!) {
+    markRoomAsRead(input: $input) {
+        lastReadAt
+    }
+}
+"""
+
+MUTATION_FOLLOW_THREAD = """
+mutation FollowThread($input: FollowThreadInput!) {
+    followThread(input: $input)
+}
+"""
+
+MUTATION_UNFOLLOW_THREAD = """
+mutation UnfollowThread($input: UnfollowThreadInput!) {
+    unfollowThread(input: $input)
+}
+"""
+
+MUTATION_SEND_TYPING = """
+mutation SendTypingIndicator($input: SendTypingIndicatorInput!) {
+    sendTypingIndicator(input: $input)
+}
+"""
+
+MUTATION_START_DM = """
+mutation StartDM($input: StartDMInput!) {
+    startDM(input: $input) {
+        id
+        name
+    }
+}
+"""
+
+MUTATION_UPDATE_MY_PROFILE = """
+mutation UpdateMyProfile($input: UpdateMyProfileInput!) {
+    updateMyProfile(input: $input) {
+        id
+        login
+        displayName
+    }
+}
+"""
+
+MUTATION_UPLOAD_MY_AVATAR = """
+mutation UploadMyAvatar($input: UploadMyAvatarInput!) {
+    uploadMyAvatar(input: $input) {
+        id
+        avatarUrl
+    }
+}
+"""
+
+MUTATION_UPDATE_PRESENCE = """
+mutation UpdateMyPresence($input: UpdateMyPresenceInput!) {
+    updateMyPresence(input: $input)
+}
+"""
+
+MUTATION_DISMISS_NOTIFICATION = """
+mutation DismissNotification($input: DismissNotificationInput!) {
+    dismissNotification(input: $input)
+}
+"""
+
+MUTATION_DISMISS_ALL_NOTIFICATIONS = """
+mutation DismissAllNotifications {
+    dismissAllNotifications
+}
+"""
+
+# --- Subscriptions ---
+
+SUBSCRIPTION_SPACE_EVENTS = """
+subscription SpaceEvents($spaceId: ID!) {
+    mySpaceEvents(spaceId: $spaceId) {
+        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 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 PresenceChangedEvent { status }
+        }
+    }
+}
+"""
+
+SUBSCRIPTION_INSTANCE_EVENTS = """
+subscription InstanceEvents {
+    myInstanceEvents {
+        id
+        createdAt
+        actorId
+        actor { id login displayName avatarUrl }
+        event {
+            ... on InstanceConfigUpdatedEvent { instanceName motd welcomeMessage blockedUsernames }
+            ... on UserCreatedEvent { userId login displayName }
+            ... 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 NewDirectMessageNotificationEvent { roomId conversationName }
+            ... on NotificationCreatedEvent { notificationId spaceId roomId eventId inReplyToId }
+            ... on NotificationDismissedEvent { notificationId }
+            ... on ThreadFollowChangedEvent { spaceId roomId threadRootEventId isFollowing }
+            ... on NewMessageInSpaceEvent { spaceId roomId }
+            ... on RoomMarkedAsReadEvent { spaceId roomId }
+            ... on RoomLayoutUpdatedEvent { spaceId }
+            ... on SessionTerminatedEvent { reason }
+        }
+    }
+}
+"""

+ 142 - 0
src/chattolib/subscriptions.py

@@ -0,0 +1,142 @@
+"""WebSocket subscription handling for real-time Chatto events."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import Any, AsyncIterator
+
+import websockets
+from websockets.asyncio.client import ClientConnection
+
+from chattolib import queries as Q
+
+# graphql-ws protocol messages
+_GQL_CONNECTION_INIT = "connection_init"
+_GQL_CONNECTION_ACK = "connection_ack"
+_GQL_SUBSCRIBE = "subscribe"
+_GQL_NEXT = "next"
+_GQL_ERROR = "error"
+_GQL_COMPLETE = "complete"
+_GQL_PING = "ping"
+_GQL_PONG = "pong"
+
+_KEEPALIVE_INTERVAL = 30  # seconds
+
+
+def _ws_url(http_url: str) -> str:
+    """Convert an HTTP(S) GraphQL URL to its WebSocket equivalent."""
+    return http_url.replace("https://", "wss://").replace("http://", "ws://")
+
+
+async def _connect(url: str, token: str) -> ClientConnection:
+    """Establish a graphql-ws connection with auth."""
+    ws = await websockets.connect(
+        _ws_url(url),
+        subprotocols=["graphql-transport-ws"],
+        additional_headers={"Authorization": f"Bearer {token}"},
+        ping_interval=20,
+        ping_timeout=20,
+    )
+    # Send connection_init
+    await ws.send(json.dumps({"type": _GQL_CONNECTION_INIT}))
+    # Wait for connection_ack
+    ack = json.loads(await ws.recv())
+    if ack.get("type") != _GQL_CONNECTION_ACK:
+        await ws.close()
+        raise ConnectionError(f"Expected connection_ack, got: {ack}")
+    return ws
+
+
+async def _keepalive(ws: ClientConnection) -> None:
+    """Send periodic graphql-ws pings to keep the connection alive."""
+    try:
+        while True:
+            await asyncio.sleep(_KEEPALIVE_INTERVAL)
+            await ws.send(json.dumps({"type": _GQL_PING}))
+    except (asyncio.CancelledError, websockets.exceptions.ConnectionClosed):
+        pass
+
+
+async def subscribe_space_events(
+    url: str,
+    token: str,
+    space_id: str,
+    *,
+    subscription_id: str = "1",
+) -> AsyncIterator[dict[str, Any]]:
+    """Subscribe to real-time events for a space.
+
+    Yields event dicts from the mySpaceEvents subscription.
+    """
+    ws = await _connect(url, token)
+    ping_task = asyncio.create_task(_keepalive(ws))
+    try:
+        await ws.send(
+            json.dumps(
+                {
+                    "id": subscription_id,
+                    "type": _GQL_SUBSCRIBE,
+                    "payload": {
+                        "query": Q.SUBSCRIPTION_SPACE_EVENTS,
+                        "variables": {"spaceId": space_id},
+                    },
+                }
+            )
+        )
+        async for raw in ws:
+            msg = json.loads(raw)
+            msg_type = msg.get("type")
+            if msg_type == _GQL_NEXT:
+                yield msg["payload"]["data"]["mySpaceEvents"]
+            elif msg_type == _GQL_PING:
+                await ws.send(json.dumps({"type": _GQL_PONG}))
+            elif msg_type == _GQL_PONG:
+                pass
+            elif msg_type == _GQL_ERROR:
+                raise ConnectionError(f"Subscription error: {msg.get('payload')}")
+            elif msg_type == _GQL_COMPLETE:
+                break
+    finally:
+        ping_task.cancel()
+        await ws.close()
+
+
+async def subscribe_instance_events(
+    url: str,
+    token: str,
+    *,
+    subscription_id: str = "1",
+) -> AsyncIterator[dict[str, Any]]:
+    """Subscribe to real-time instance-wide events.
+
+    Yields event dicts from the myInstanceEvents subscription.
+    """
+    ws = await _connect(url, token)
+    ping_task = asyncio.create_task(_keepalive(ws))
+    try:
+        await ws.send(
+            json.dumps(
+                {
+                    "id": subscription_id,
+                    "type": _GQL_SUBSCRIBE,
+                    "payload": {"query": Q.SUBSCRIPTION_INSTANCE_EVENTS},
+                }
+            )
+        )
+        async for raw in ws:
+            msg = json.loads(raw)
+            msg_type = msg.get("type")
+            if msg_type == _GQL_NEXT:
+                yield msg["payload"]["data"]["myInstanceEvents"]
+            elif msg_type == _GQL_PING:
+                await ws.send(json.dumps({"type": _GQL_PONG}))
+            elif msg_type == _GQL_PONG:
+                pass
+            elif msg_type == _GQL_ERROR:
+                raise ConnectionError(f"Subscription error: {msg.get('payload')}")
+            elif msg_type == _GQL_COMPLETE:
+                break
+    finally:
+        ping_task.cancel()
+        await ws.close()

+ 147 - 0
src/chattolib/types.py

@@ -0,0 +1,147 @@
+"""Dataclasses mirroring Chatto GraphQL object types."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+
+
+# --- Enums ---
+
+
+class FitMode(str, Enum):
+    CONTAIN = "CONTAIN"
+    COVER = "COVER"
+    EXACT = "EXACT"
+
+
+class NotificationLevel(str, Enum):
+    DEFAULT = "DEFAULT"
+    MUTED = "MUTED"
+    NORMAL = "NORMAL"
+    ALL_MESSAGES = "ALL_MESSAGES"
+
+
+class PresenceStatus(str, Enum):
+    OFFLINE = "OFFLINE"
+    ONLINE = "ONLINE"
+    AWAY = "AWAY"
+    DO_NOT_DISTURB = "DO_NOT_DISTURB"
+
+
+class TimeFormat(str, Enum):
+    UNSPECIFIED = "UNSPECIFIED"
+    TWELVE_HOUR = "TWELVE_HOUR"
+    TWENTY_FOUR_HOUR = "TWENTY_FOUR_HOUR"
+
+
+class VideoProcessingStatus(str, Enum):
+    PENDING = "PENDING"
+    PROCESSING = "PROCESSING"
+    COMPLETED = "COMPLETED"
+    FAILED = "FAILED"
+
+
+# --- Core types ---
+
+
+@dataclass
+class User:
+    id: str
+    login: str
+    display_name: str
+    created_at: datetime | None = None
+    avatar_url: str | None = None
+    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
+    description: str | None = None
+    archived: bool = False
+    auto_join: bool = False
+    has_unread: bool = False
+    has_mention: bool = False
+
+
+@dataclass
+class Attachment:
+    id: str
+    space_id: str
+    room_id: str
+    filename: str
+    content_type: str
+    size: int
+    url: str | None = None
+    width: int | None = None
+    height: int | None = None
+
+
+@dataclass
+class LinkPreview:
+    url: str
+    title: str | None = None
+    description: str | None = None
+    image_url: str | None = None
+    site_name: str | None = None
+    embed_type: str | None = None
+    embed_id: str | None = None
+
+
+@dataclass
+class Reaction:
+    emoji: str
+    count: int
+    has_reacted: bool = False
+    users: list[User] = field(default_factory=list)
+
+
+@dataclass
+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
+    actor: User | None = None
+    attachments: list[Attachment] = field(default_factory=list)
+    reactions: list[Reaction] = field(default_factory=list)
+    in_reply_to: str | None = None
+    in_thread: str | None = None
+    reply_count: int = 0
+    link_preview: LinkPreview | None = None
+
+
+@dataclass
+class RoomEventsPage:
+    events: list[MessageEvent]
+    has_older: bool = False
+    has_newer: bool = False
+
+
+@dataclass
+class FollowedThread:
+    space_id: str
+    room_id: str
+    thread_root_event_id: str
+    reply_count: int = 0
+    last_reply_at: datetime | None = None
+    has_unread: bool = False

+ 0 - 0
tests/__init__.py


+ 355 - 0
tests/test_client.py

@@ -0,0 +1,355 @@
+"""Tests for ChattoClient using respx to mock HTTP."""
+
+import pytest
+import respx
+import httpx
+
+from chattolib.client import ChattoClient
+from chattolib.exceptions import ChattoGraphQLError, ChattoAuthError
+from chattolib.types import PresenceStatus
+
+
+@pytest.fixture
+def mock_api():
+    with respx.mock(base_url="https://chat.chatto.run") as api:
+        yield api
+
+
+@pytest.fixture
+def client():
+    return ChattoClient(token="test-token")
+
+
+def _gql_response(data: dict) -> httpx.Response:
+    return httpx.Response(200, json={"data": data})
+
+
+async def test_me(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "me": {
+                    "id": "u1",
+                    "login": "alice",
+                    "displayName": "Alice",
+                    "createdAt": "2025-01-01T00:00:00Z",
+                    "avatarUrl": None,
+                    "presenceStatus": "ONLINE",
+                }
+            }
+        )
+    )
+    async with client:
+        user = await client.me()
+    assert user.id == "u1"
+    assert user.login == "alice"
+    assert user.display_name == "Alice"
+
+
+async def test_spaces(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,
+                    }
+                ]
+            }
+        )
+    )
+    async with client:
+        spaces = await client.spaces()
+    assert len(spaces) == 1
+    assert spaces[0].name == "General"
+    assert spaces[0].viewer_is_member is True
+
+
+async def test_graphql_error(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=httpx.Response(
+            200,
+            json={"errors": [{"message": "Not authorized"}]},
+        )
+    )
+    async with client:
+        with pytest.raises(ChattoGraphQLError, match="Not authorized"):
+            await client.me()
+
+
+async def test_auth_error(mock_api, client):
+    mock_api.post("/api/graphql").mock(return_value=httpx.Response(401))
+    async with client:
+        with pytest.raises(ChattoAuthError):
+            await client.me()
+
+
+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"}}
+        )
+    )
+    async with client:
+        result = await client.post_message("s1", "r1", "Hello!")
+    assert result["id"] == "e1"
+
+
+async def test_room_events(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "roomEvents": {
+                    "events": [
+                        {
+                            "id": "e1",
+                            "createdAt": "2025-01-01T00:00:00Z",
+                            "actorId": "u1",
+                            "actor": {
+                                "id": "u1",
+                                "login": "alice",
+                                "displayName": "Alice",
+                                "avatarUrl": None,
+                            },
+                            "event": {
+                                "spaceId": "s1",
+                                "roomId": "r1",
+                                "body": "Hello",
+                                "attachments": [],
+                                "reactions": [],
+                                "inReplyTo": None,
+                                "inThread": None,
+                                "replyCount": 0,
+                                "lastReplyAt": None,
+                                "linkPreview": None,
+                            },
+                        }
+                    ],
+                    "hasOlder": False,
+                    "hasNewer": False,
+                }
+            }
+        )
+    )
+    async with client:
+        page = await client.room_events("s1", "r1", limit=10)
+    assert len(page.events) == 1
+    assert page.events[0].body == "Hello"
+    assert page.events[0].actor.login == "alice"
+
+
+async def test_login():
+    with respx.mock(base_url="https://chat.chatto.run") as api:
+        api.post("/auth/login").mock(
+            return_value=httpx.Response(
+                200,
+                json={"success": True, "token": "cht_abc123", "user": {"id": "u1", "login": "alice"}},
+                headers={"set-cookie": "chatto_session=xyz; Path=/; HttpOnly"},
+            )
+        )
+        client = await ChattoClient.login("alice", "password123")
+        assert client._token == "cht_abc123"
+        assert client._session_cookie == "xyz"
+        await client.close()
+
+
+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"}))
+        with pytest.raises(ChattoAuthError, match="Invalid credentials"):
+            await ChattoClient.login("bad", "creds")
+
+
+async def test_create_user(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "createUser": {
+                    "id": "u2",
+                    "login": "newbot",
+                    "displayName": "New Bot",
+                    "createdAt": "2025-01-01T00:00:00Z",
+                    "avatarUrl": None,
+                    "presenceStatus": None,
+                }
+            }
+        )
+    )
+    async with client:
+        user = await client.create_user("newbot", "New Bot", "pass123")
+    assert user.id == "u2"
+    assert user.login == "newbot"
+    assert user.display_name == "New Bot"
+
+
+async def test_edit_message(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response({"editMessage": {"id": "e1"}})
+    )
+    async with client:
+        result = await client.edit_message("s1", "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})
+    )
+    async with client:
+        result = await client.delete_message("s1", "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})
+    )
+    async with client:
+        result = await client.add_reaction("s1", "r1", "e1", "👍")
+    assert result is True
+
+
+async def test_remove_reaction(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response({"removeReaction": True})
+    )
+    async with client:
+        result = await client.remove_reaction("s1", "r1", "e1", "👍")
+    assert result is True
+
+
+async def test_create_space(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {"createSpace": {"id": "s1", "name": "Test Space", "description": "A test"}}
+        )
+    )
+    async with client:
+        space = await client.create_space("Test Space", description="A test")
+    assert space.id == "s1"
+    assert space.name == "Test Space"
+
+
+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}}
+        )
+    )
+    async with client:
+        room = await client.create_room("s1", "general")
+    assert room.id == "r1"
+    assert room.name == "general"
+
+
+async def test_update_my_profile(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "updateMyProfile": {
+                    "id": "u1",
+                    "login": "newname",
+                    "displayName": "New Name",
+                }
+            }
+        )
+    )
+    async with client:
+        user = await client.update_my_profile(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})
+    )
+    async with client:
+        result = await client.update_presence(PresenceStatus.ONLINE)
+    assert result is True
+
+
+async def test_upload_my_avatar(mock_api, client, tmp_path):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {"uploadMyAvatar": {"id": "u1", "avatarUrl": "https://example.com/avatar.jpg"}}
+        )
+    )
+    avatar = tmp_path / "avatar.jpg"
+    avatar.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
+    async with client:
+        result = await client.upload_my_avatar(str(avatar))
+    assert result["avatarUrl"] == "https://example.com/avatar.jpg"
+
+
+async def test_user(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "user": {
+                    "id": "u1",
+                    "login": "alice",
+                    "displayName": "Alice",
+                    "createdAt": "2025-01-01T00:00:00Z",
+                    "avatarUrl": None,
+                    "presenceStatus": "ONLINE",
+                }
+            }
+        )
+    )
+    async with client:
+        user = await client.user("u1")
+    assert user.id == "u1"
+    assert user.login == "alice"
+
+
+async def test_user_by_login(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response(
+            {
+                "userByLogin": {
+                    "id": "u1",
+                    "login": "alice",
+                    "displayName": "Alice",
+                    "createdAt": None,
+                    "avatarUrl": None,
+                    "presenceStatus": None,
+                }
+            }
+        )
+    )
+    async with client:
+        user = await client.user_by_login("alice")
+    assert user.login == "alice"
+
+
+async def test_start_dm(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response({"startDM": {"id": "r1", "name": "DM"}})
+    )
+    async with client:
+        result = await client.start_dm(["u1", "u2"])
+    assert result["id"] == "r1"
+
+
+async def test_dismiss_notification(mock_api, client):
+    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
+
+
+async def test_dismiss_all_notifications(mock_api, client):
+    mock_api.post("/api/graphql").mock(
+        return_value=_gql_response({"dismissAllNotifications": True})
+    )
+    async with client:
+        result = await client.dismiss_all_notifications()
+    assert result is True

+ 65 - 0
tests/test_integration.py

@@ -0,0 +1,65 @@
+"""Integration tests against the live Chatto API.
+
+Run with:  pytest tests/test_integration.py -v
+Requires: CHATTO_LOGIN and CHATTO_PASSWORD environment variables.
+"""
+
+import os
+
+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",
+)
+
+
+@pytest.fixture
+async def client():
+    c = await ChattoClient.login(
+        os.environ["CHATTO_LOGIN"],
+        os.environ["CHATTO_PASSWORD"],
+    )
+    async with c:
+        yield c
+
+
+async def test_login_and_me(client):
+    user = await client.me()
+    assert user.id
+    assert user.login == os.environ["CHATTO_LOGIN"]
+    print(f"Logged in as {user.login} (id={user.id})")
+
+
+async def test_invalid_login():
+    with pytest.raises(ChattoAuthError, match="Invalid credentials"):
+        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_create_user(client):
+    """Test creating a new user via the createUser mutation (requires admin)."""
+    import random
+    import string
+
+    suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+    login = f"testbot_{suffix}"
+    display_name = f"Test Bot {suffix}"
+    password = "TestPass123!"
+
+    user = await client.create_user(login, display_name, password)
+    assert user.id
+    assert user.login == login
+    assert user.display_name == display_name
+    print(f"Created user: {user.login} (id={user.id})")