Sfoglia il codice sorgente

Add chattolib bot framework (standard library for Chatto bots)

- New chattolib/bot.py: Bot facade over ChattoClient with key-based
  Bot.login(), an async event dispatcher over the realtime v2 projection
  stream, and flattened verbs (say/reply/react/set_status/join_room/...)
- Typed events: BotMessageEvent, BotReactionEvent, BotPresenceEvent,
  BotTypingEvent, BotRoomEvent, BotUserEvent; '*' wildcard handlers
- Bot exported from the top-level namespace (guarded for non-realtime installs)
- examples/bot/echo_bot.py: runnable mention-echo bot
- docs/bots.md: auth model, event table, verb reference
- tests/test_bot.py: 10 tests for the verbs and dispatcher
the-codemancer 2 mesi fa
parent
commit
130389ae81
5 ha cambiato i file con 991 aggiunte e 0 eliminazioni
  1. 127 0
      docs/bots.md
  2. 82 0
      examples/bot/echo_bot.py
  3. 8 0
      src/chattolib/__init__.py
  4. 557 0
      src/chattolib/bot.py
  5. 217 0
      tests/test_bot.py

+ 127 - 0
docs/bots.md

@@ -0,0 +1,127 @@
+# Building bots with chattolib
+
+Chatto 0.5.0 introduced **bot accounts**: user identities flagged `is_bot`
+that authenticate with a **key** instead of a username and password. This
+module makes chattolib the standard library for building those bots.
+
+## How bot auth works
+
+A bot key (it looks like `cht_BK_...`) is used **directly as a bearer token**.
+There is no `/auth/login` round-trip — the key *is* the credential. When you
+present it, the server resolves it to a `User` with `is_bot: true` and a
+capability grant set, and every Connect RPC you make is authorized by that
+grant set.
+
+```python
+from chattolib.bot import Bot
+
+async with await Bot.login("cht_BK_...") as bot:
+    print(bot.login_name, bot.user.is_bot)   # 'felix_bot' True
+```
+
+`Bot.login` also accepts `base_url=` to target a self-hosted or preview
+deployment (e.g. `https://next.preview.chatto.run`).
+
+## The three layers
+
+A `Bot` is a thin, opinionated layer over the raw `ChattoClient`:
+
+1. **Key login** — `Bot.login(key, base_url=...)`.
+2. **An event dispatcher** — `bot.run()` opens the realtime stream and routes
+   incoming events to the async handlers you register with `bot.on(...)`.
+3. **Flattened verbs** — `say`, `reply`, `react`, `set_status`, `join_room`,
+   `create_room`, and friends, so a bot's brain reads like natural language.
+
+Under the hood the dispatcher consumes the realtime **projection** stream
+(protocol v2): durable state (messages, reactions, room changes, presence)
+arrives as `RealtimeProjectionEvent`s, while transient signals (typing,
+presence) arrive as live `RealtimeEvent`s. You don't have to think about that
+split — handlers just receive typed events.
+
+## Events
+
+| `kind`      | Event class          | When it fires                                   |
+|-------------|----------------------|-------------------------------------------------|
+| `"message"` | `BotMessageEvent`    | a new/updated message in a room you can see      |
+| `"reaction"`| `BotReactionEvent`   | a reaction added to / removed from a message    |
+| `"presence"`| `BotPresenceEvent`   | a user's presence status changed                 |
+| `"typing"`  | `BotTypingEvent`     | a user started/stopped typing in a room/thread  |
+| `"room"`    | `BotRoomEvent`       | a room was created/updated/archived, or a member joined/left |
+| `"user"`    | `BotUserEvent`       | a user profile was upserted or removed          |
+| `"*"`       | (any)                | **all** events — use for logging or a catch-all |
+
+Every event carries a `bot` reference, so a handler can act on it directly:
+
+```python
+async def on_message(event):
+    if event.is_mention:
+        await event.bot.reply(event, "hi!")
+```
+
+`BotMessageEvent` exposes `message` (the full `Message`), `actor` (the
+author's `User`, when the server included it), `room_id`, `body`, and a
+best-effort `is_mention` flag.
+
+## A complete bot
+
+```python
+import asyncio
+from chattolib.bot import Bot, BotMessageEvent
+from chattolib.types import PresenceStatus
+
+async def on_message(event: BotMessageEvent) -> None:
+    if event.is_mention:
+        await event.bot.reply(event, f"you said: {event.body!r}")
+
+async def main() -> None:
+    async with await Bot.login("cht_BK_...") as bot:
+        bot.on("message", on_message)
+        await bot.set_presence(PresenceStatus.ONLINE)
+        for room in await bot.list_rooms():
+            if room.room:
+                await bot.join_room(room.room.id)
+        await bot.run()   # blocks until the stream closes
+
+asyncio.run(main())
+```
+
+See [`examples/bot/echo_bot.py`](../examples/bot/echo_bot.py) for a runnable
+version that also handles presence and typing, and installs clean signal
+handlers for Ctrl-C.
+
+## The verb reference
+
+| Method | Wraps | Notes |
+|--------|-------|-------|
+| `await bot.say(room_id, body)` | `post_message` | post a plain message |
+| `await bot.reply(target, body)` | `post_message` | `target` is a `Message` or `BotMessageEvent`; threads to its room |
+| `await bot.react(room_id, event_id, emoji)` | `add_reaction` | |
+| `await bot.unreact(room_id, event_id, emoji)` | `remove_reaction` | |
+| `await bot.set_presence(status)` | `update_presence` | `ONLINE` / `AWAY` / `DO_NOT_DISTURB` |
+| `await bot.set_status(emoji, text)` | `update_custom_status` | |
+| `await bot.clear_status()` | `delete_custom_status` | |
+| `await bot.join_room(room_id)` | `join_room` | |
+| `await bot.leave_room(room_id)` | `leave_room` | |
+| `await bot.create_room(name, group_id, ...)` | `create_room` | |
+| `await bot.list_rooms()` | `list_rooms` | rooms the bot is a member of |
+| `await bot.mark_read(room_id)` | `mark_room_as_read` | |
+
+Anything not wrapped here is available on `bot.client` (the raw
+`ChattoClient`) and `bot.client.services` (the raw ConnectRPC stubs), so the
+framework never becomes a ceiling on what you can do.
+
+## Reconnection
+
+`bot.run()` reconnects automatically when the server drops the connection,
+resuming from the last `resume_cursor`. Pass `until=<asyncio.Event>` to stop
+the loop on an external signal, and `retained_room_ids=[...]` to have the
+server re-send the projection for specific rooms on (re)connect.
+
+## Requirements
+
+The bot framework needs the **realtime** extra (it drives the WebSocket
+stream):
+
+```
+pip install "chattolib[realtime]"
+```

+ 82 - 0
examples/bot/echo_bot.py

@@ -0,0 +1,82 @@
+"""A minimal, runnable Chatto bot using the chattolib bot framework.
+
+Run it against a preview deployment::
+
+    export CHATTO_BOT_KEY="cht_BK_..."
+    export CHATTO_BASE_URL="https://next.preview.chatto.run"   # optional
+    python examples/bot/echo_bot.py
+
+The bot:
+
+* logs in with its key (used directly as a bearer token),
+* sets its presence to online,
+* joins every room it can see,
+* and echoes back any message that mentions it.
+
+It also reacts to presence changes and typing, to show the full event
+surface. Ctrl-C stops the bot cleanly.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import os
+import signal
+
+from chattolib.bot import Bot, BotMessageEvent, BotPresenceEvent, BotTypingEvent
+from chattolib.types import PresenceStatus
+
+
+async def on_message(bot: Bot, event: BotMessageEvent) -> None:
+    # Only answer when we're actually mentioned, to keep things quiet.
+    if not event.is_mention:
+        return
+    if event.actor is not None and event.actor.id == bot.user.id:
+        return  # ignore our own echoes
+    await bot.reply(event, f"you said: {event.body!r}")
+
+
+async def on_presence(bot: Bot, event: BotPresenceEvent) -> None:
+    print(f"[presence] {event.user_id} is now {event.status.value}")
+
+
+async def on_typing(bot: Bot, event: BotTypingEvent) -> None:
+    print(f"[typing] someone is typing in {event.room_id}")
+
+
+async def main() -> None:
+    key = os.environ.get("CHATTO_BOT_KEY")
+    if not key:
+        raise SystemExit("set CHATTO_BOT_KEY to your bot key (cht_BK_...)")
+    base_url = os.environ.get("CHATTO_BASE_URL")
+
+    async with await Bot.login(key, base_url=base_url) as bot:
+        print(f"logged in as {bot.login_name!r} (is_bot={bot.user.is_bot})")
+
+        # Register handlers. `on` returns the handler, so it doubles as a
+        # decorator if you prefer that style.
+        bot.on("message", lambda e: on_message(bot, e))
+        bot.on("presence", lambda e: on_presence(bot, e))
+        bot.on("typing", lambda e: on_typing(bot, e))
+
+        # Get online and join the rooms we can see.
+        await bot.set_presence(PresenceStatus.ONLINE)
+        for room in await bot.list_rooms():
+            room_id = room.room.id if room.room else ""
+            if room_id:
+                await bot.join_room(room_id)
+                print(f"joined {room_id}")
+
+        # Run until interrupted.
+        stop = asyncio.Event()
+        loop = asyncio.get_running_loop()
+        for sig in (signal.SIGINT, signal.SIGTERM):
+            with contextlib.suppress(NotImplementedError):
+                loop.add_signal_handler(sig, stop.set)
+        await bot.run(until=stop)
+        print("stopped")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 8 - 0
src/chattolib/__init__.py

@@ -10,6 +10,12 @@ from chattolib.realtime import (
     ServerHello,
     stream_events,
 )
+
+try:  # the bot framework needs the optional realtime extra
+    from chattolib.bot import Bot, BotError
+except ImportError:  # pragma: no cover - realtime extra not installed
+    Bot = None  # type: ignore[assignment,misc]
+    BotError = None  # type: ignore[assignment,misc]
 from chattolib.types import (
     ActiveCall,
     AdminMember,
@@ -73,6 +79,8 @@ from chattolib.types import (
 )
 
 __all__ = [
+    "Bot",
+    "BotError",
     "ChattoClient",
     "ChattoError",
     "ChattoConnectError",

+ 557 - 0
src/chattolib/bot.py

@@ -0,0 +1,557 @@
+"""A small, opinionated framework for building bots on Chatto.
+
+Chatto 0.5.0 introduced *bot accounts*: user identities flagged ``is_bot``
+that authenticate with a **key** (e.g. ``cht_BK_...``) rather than a
+username/password. The key is used **directly as a bearer token** — there is
+no ``/auth/login`` round-trip — and it resolves to a ``User`` with a
+capability grant set.
+
+This module turns :class:`chattolib.client.ChattoClient` into the standard
+library for bots by adding three things on top of the raw client:
+
+* **Key-based login** — :meth:`Bot.login` builds an authenticated client from
+  a bot key in one call.
+* **An event dispatcher** — :meth:`Bot.run` opens the realtime stream and
+  routes incoming events (messages, reactions, mentions, presence, typing,
+  room changes) to the async handlers you register.
+* **Flattened verbs** — :meth:`Bot.say`, :meth:`Bot.reply`,
+  :meth:`Bot.react`, :meth:`Bot.set_status`, :meth:`Bot.join_room`,
+  :meth:`Bot.create_room`, and friends, so a bot's "brain" reads like
+  natural language instead of protobuf plumbing.
+
+A minimal bot::
+
+    import asyncio
+    from chattolib.bot import Bot
+
+    async def on_message(event):
+        if event.body.startswith("!hello"):
+            await event.bot.reply(event, "hi!")
+
+    async def main():
+        async with await Bot.login("cht_BK_...") as bot:
+            bot.on("message", on_message)
+            await bot.run()
+
+    asyncio.run(main())
+
+Requires the ``chattolib[realtime]`` extra for the live stream.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from chattolib import _pb  # noqa: F401 — installs the generated pb import path
+from chattolib._pb.chatto.api.v1 import presence_pb2
+from chattolib.client import ChattoClient
+from chattolib.exceptions import ChattoError
+from chattolib.realtime import (
+    ChattoRealtimeCloseError,
+    ChattoRealtimeError,
+    RealtimeConnection,
+    RealtimeEvent,
+    RealtimeProjectionEvent,
+    stream_events,
+)
+from chattolib.types import (
+    Message,
+    PresenceStatus,
+    Room,
+    RoomWithViewerState,
+    User,
+)
+
+
+def _presence(value: Any) -> PresenceStatus:
+    """Coerce a protobuf presence value (int or enum) to a PresenceStatus."""
+    name = value.name if hasattr(value, "name") else presence_pb2.PresenceStatus.Name(int(value))
+    # The protobuf name is the *value* of our StrEnum (e.g. "PRESENCE_STATUS_ONLINE"),
+    # so look it up by value, not by member name.
+    for member in PresenceStatus:
+        if member.value == name:
+            return member
+    return PresenceStatus.UNSPECIFIED
+
+
+__all__ = [
+    "Bot",
+    "BotError",
+    "BotEvent",
+    "BotMessageEvent",
+    "BotPresenceEvent",
+    "BotReactionEvent",
+    "BotRoomEvent",
+    "BotTypingEvent",
+    "BotUserEvent",
+]
+
+
+class BotError(ChattoError):
+    """Raised for bot-framework-level errors (bad handlers, bad keys, ...)."""
+
+
+# ---------------------------------------------------------------------------
+# Event types
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class BotEvent:
+    """Base class for every event the dispatcher delivers to a handler.
+
+    ``bot`` is the owning :class:`Bot`, so a handler can act on the event
+    (e.g. ``await event.bot.reply(event, "...")``) without closing over
+    globals.
+    """
+
+    bot: Bot
+    kind: str
+
+    async def _noop(self) -> None:  # pragma: no cover - interface
+        ...
+
+
+@dataclass
+class BotMessageEvent(BotEvent):
+    """A new or updated message in a room the bot can see.
+
+    ``message`` is the full :class:`Message`. ``actor`` is the author's
+    :class:`User` when the server included it; otherwise ``None`` (hydrate
+    with ``await bot.get_user(message.actor_id)`` if you need it).
+    """
+
+    message: Message
+    actor: User | None = None
+
+    @property
+    def room_id(self) -> str:
+        return self.message.room_id
+
+    @property
+    def body(self) -> str | None:
+        return self.message.body
+
+    @property
+    def is_mention(self) -> bool:
+        """True when this message mentions the bot (best-effort)."""
+        me = self.bot.user
+        if me is None or not self.message.body:
+            return False
+        return f"@{me.login}" in self.message.body or me.display_name in self.message.body
+
+
+@dataclass
+class BotReactionEvent(BotEvent):
+    """A reaction was added to (or removed from) a message."""
+
+    room_id: str
+    message_event_id: str
+    emoji: str
+    user_id: str
+    added: bool = True
+
+
+@dataclass
+class BotPresenceEvent(BotEvent):
+    """A user's presence status changed."""
+
+    user_id: str
+    status: PresenceStatus
+
+
+@dataclass
+class BotTypingEvent(BotEvent):
+    """A user started (or stopped) typing in a room/thread."""
+
+    room_id: str
+    thread_root_event_id: str | None = None
+
+
+@dataclass
+class BotRoomEvent(BotEvent):
+    """A room lifecycle change (created, updated, archived, member join/...)."""
+
+    room: Room | None = None
+    detail: str = ""  # e.g. "created", "updated", "archived", "user_joined"
+
+
+@dataclass
+class BotUserEvent(BotEvent):
+    """A user profile upsert or removal observed in the projection."""
+
+    user: User | None = None
+    removed: bool = False
+
+
+# ---------------------------------------------------------------------------
+# The Bot
+# ---------------------------------------------------------------------------
+
+
+Handler = Callable[[BotEvent], Awaitable[None]]
+
+
+class Bot:
+    """A Chatto bot: an authenticated client plus an event dispatcher and a
+    set of convenience verbs.
+
+    Create one with :meth:`login` (from a bot key) or :meth:`from_client`
+    (wrapping an already-authenticated :class:`ChattoClient`).
+    """
+
+    def __init__(
+        self,
+        client: ChattoClient,
+        *,
+        base_url: str | None = None,
+    ) -> None:
+        self._client = client
+        self._base_url = base_url or client.base_url
+        self._user: User | None = None
+        self._handlers: dict[str, list[Handler]] = {}
+        self._connection: RealtimeConnection | None = None
+        self._running = False
+
+    # -- construction ------------------------------------------------------
+
+    @classmethod
+    async def login(
+        cls,
+        key: str,
+        *,
+        base_url: str | None = None,
+    ) -> Bot:
+        """Authenticate a bot with its **key**.
+
+        The key (e.g. ``cht_BK_...``) is used directly as the bearer token —
+        no username/password. ``base_url`` defaults to the public Chatto
+        server; pass it to target a self-hosted or preview deployment.
+        """
+        if base_url is None:
+            base_url = ChattoClient.DEFAULT_BASE_URL
+        client = ChattoClient(token=key, base_url=base_url)
+        bot = cls(client, base_url=base_url)
+        await bot._probe_identity()
+        return bot
+
+    @classmethod
+    def from_client(cls, client: ChattoClient) -> Bot:
+        """Wrap an already-authenticated client (e.g. a human login)."""
+        return cls(client)
+
+    async def _probe_identity(self) -> None:
+        """Resolve the bot's own identity and warn (not fail) if not a bot."""
+        try:
+            self._user = await self._client.me()
+        except ChattoError:
+            self._user = None
+            return
+        if self._user is not None and not self._user.is_bot:
+            # Not fatal — a human client can drive the same verbs — but surface
+            # it so a misconfigured key is obvious.
+            import warnings
+
+            warnings.warn(
+                f"Bot key authenticated as {self._user.login!r}, which is not "
+                "flagged is_bot. It will still work, but this is usually a "
+                "misconfigured key.",
+                stacklevel=2,
+            )
+
+    # -- identity ----------------------------------------------------------
+
+    @property
+    def client(self) -> ChattoClient:
+        """The underlying :class:`ChattoClient` for any RPC not wrapped here."""
+        return self._client
+
+    @property
+    def base_url(self) -> str:
+        return self._base_url
+
+    @property
+    def user(self) -> User | None:
+        """The bot's own :class:`User` profile, once known."""
+        return self._user
+
+    @property
+    def login_name(self) -> str | None:
+        return self._user.login if self._user else None
+
+    @property
+    def display_name(self) -> str | None:
+        return self._user.display_name if self._user else None
+
+    # -- lifecycle ---------------------------------------------------------
+
+    async def __aenter__(self) -> Bot:
+        return self
+
+    async def __aexit__(self, *exc: Any) -> None:
+        await self.close()
+
+    async def close(self) -> None:
+        if self._connection is not None:
+            with contextlib.suppress(Exception):
+                await self._connection.close()
+            self._connection = None
+        self._running = False
+        await self._client.close()
+
+    # -- event registration ------------------------------------------------
+
+    def on(self, kind: str, handler: Handler) -> Handler:
+        """Register ``handler`` for events of ``kind``.
+
+        ``kind`` is one of: ``message``, ``reaction``, ``presence``,
+        ``typing``, ``room``, ``user``, ``*`` (all events). ``handler`` is an
+        ``async def`` taking a single :class:`BotEvent`. Returns the handler
+        so ``on`` can be used as a decorator.
+        """
+        self._handlers.setdefault(kind, []).append(handler)
+        return handler
+
+    def off(self, kind: str, handler: Handler) -> None:
+        with contextlib.suppress(ValueError):
+            self._handlers[kind].remove(handler)
+
+    async def _dispatch(self, event: BotEvent) -> None:
+        handlers = list(self._handlers.get(event.kind, [])) + list(self._handlers.get("*", []))
+        for handler in handlers:
+            try:
+                await handler(event)
+            except Exception:  # noqa: BLE001 - one bad handler must not kill the loop
+                import logging
+
+                logging.exception("bot handler for %r raised", event.kind)
+
+    # -- the run loop ------------------------------------------------------
+
+    # -- verbs: messaging --------------------------------------------------
+
+    async def say(self, room_id: str, body: str = "") -> Message:
+        """Post a message to a room. Returns the created :class:`Message`."""
+        return await self._client.post_message(room_id, body)
+
+    async def reply(
+        self,
+        target: BotMessageEvent | Message,
+        body: str,
+        *,
+        also_send_to_channel: bool = False,
+    ) -> Message:
+        """Reply to a message (or a :class:`BotMessageEvent`) in its room/thread."""
+        if isinstance(target, BotMessageEvent):
+            target = target.message
+        return await self._client.post_message(
+            target.room_id,
+            body,
+            thread_root_event_id=target.thread_root_event_id,
+            in_reply_to=target.id,
+            also_send_to_channel=also_send_to_channel,
+        )
+
+    async def react(self, room_id: str, message_event_id: str, emoji: str) -> bool:
+        """Add an emoji reaction to a message."""
+        return await self._client.add_reaction(room_id, message_event_id, emoji)
+
+    async def unreact(self, room_id: str, message_event_id: str, emoji: str) -> bool:
+        """Remove one of the bot's emoji reactions from a message."""
+        return await self._client.remove_reaction(room_id, message_event_id, emoji)
+
+    # -- verbs: presence & status -----------------------------------------
+
+    async def set_presence(self, status: PresenceStatus) -> PresenceStatus:
+        """Set the bot's presence (``ONLINE`` / ``IDLE`` / ``DO_NOT_DISTURB``)."""
+        return await self._client.update_presence(status)
+
+    async def set_status(self, emoji: str, text: str) -> dict[str, Any]:
+        """Set the bot's custom status (e.g. a "working on X" note)."""
+        return await self._client.update_custom_status(emoji, text)
+
+    async def clear_status(self) -> dict[str, Any]:
+        """Clear the bot's custom status."""
+        return await self._client.delete_custom_status()
+
+    # -- verbs: rooms ------------------------------------------------------
+
+    async def join_room(self, room_id: str) -> Room:
+        """Join a room. Returns the joined :class:`Room`."""
+        return await self._client.join_room(room_id)
+
+    async def leave_room(self, room_id: str) -> bool:
+        """Leave a room."""
+        return await self._client.leave_room(room_id)
+
+    async def create_room(
+        self,
+        name: str,
+        group_id: str,
+        *,
+        description: str = "",
+        universal: bool = False,
+    ) -> Room:
+        """Create a room in a room group."""
+        return await self._client.create_room(
+            name, group_id, description=description, universal=universal
+        )
+
+    async def list_rooms(self) -> list[RoomWithViewerState]:
+        """List the rooms the bot is a member of."""
+        return await self._client.list_rooms()
+
+    async def mark_read(self, room_id: str) -> None:
+        """Mark a room as read (clears its unread state for the bot)."""
+        await self._client.mark_room_as_read(room_id)
+
+    # -- the run loop ------------------------------------------------------
+
+    async def run(
+        self,
+        *,
+        resume_cursor: str | None = None,
+        retained_room_ids: list[str] | None = None,
+        until: asyncio.Event | None = None,
+    ) -> None:
+        """Connect the realtime stream and dispatch events until it closes.
+
+        Reconnects automatically (with the last ``resume_cursor``) when the
+        server drops the connection, unless a fatal protocol error is raised.
+        Pass ``until`` to stop the loop on an external signal.
+        """
+        self._running = True
+        cursor = resume_cursor
+        while self._running:
+            if until is not None and until.is_set():
+                break
+            try:
+                async for frame in stream_events(
+                    self._client,
+                    resume_cursor=cursor,
+                    retained_room_ids=retained_room_ids,
+                ):
+                    if until is not None and until.is_set():
+                        break
+                    if isinstance(frame, RealtimeProjectionEvent):
+                        cursor = frame.resume_cursor or cursor
+                        await self._handle_projection(frame)
+                    else:
+                        await self._handle_live(frame)
+            except ChattoRealtimeCloseError as close:
+                if not close.reconnect:
+                    raise
+                # reconnectable close: loop again, resuming from the cursor
+                continue
+            except ChattoRealtimeError:
+                raise
+        self._running = False
+
+    async def _handle_live(self, frame: RealtimeEvent) -> None:
+        if frame.kind == "presence_changed":
+            payload = frame.payload
+            event = BotPresenceEvent(
+                bot=self,
+                kind="presence",
+                user_id=payload.user_id,
+                status=_presence(payload.status),
+            )
+            await self._dispatch(event)
+        elif frame.kind == "user_typing":
+            payload = frame.payload
+            await self._dispatch(
+                BotTypingEvent(
+                    bot=self,
+                    kind="typing",
+                    room_id=payload.room_id,
+                    thread_root_event_id=payload.thread_root_event_id or None,
+                )
+            )
+        elif frame.kind == "session_terminated":
+            self._running = False
+
+    async def _handle_projection(self, frame: RealtimeProjectionEvent) -> None:
+        for op in frame.operations:
+            case = op.operation
+            if case == "room_timeline_event_upsert":
+                await self._on_timeline_upsert(op)
+            elif case == "presences_replace":
+                for user_id, status in op.payload.statuses.items():
+                    await self._dispatch(
+                        BotPresenceEvent(
+                            bot=self,
+                            kind="presence",
+                            user_id=user_id,
+                            status=_presence(status),
+                        )
+                    )
+            elif case == "room_upsert":
+                room = op.payload.room
+                if room is not None:
+                    await self._dispatch(
+                        BotRoomEvent(
+                            bot=self,
+                            kind="room",
+                            room=Room.parse(_pb_to_dict(room)),
+                            detail="upsert",
+                        )
+                    )
+            elif case == "room_remove":
+                await self._dispatch(
+                    BotRoomEvent(bot=self, kind="room", room=None, detail="removed")
+                )
+            elif case == "user_upsert":
+                await self._dispatch(
+                    BotUserEvent(
+                        bot=self,
+                        kind="user",
+                        user=User.parse(_pb_to_dict(op.payload)),
+                    )
+                )
+            elif case == "user_remove":
+                await self._dispatch(BotUserEvent(bot=self, kind="user", user=None, removed=True))
+
+    async def _on_timeline_upsert(self, op: Any) -> None:
+        payload = op.payload
+        event = payload.event
+        case = event.WhichOneof("event") if event is not None else None
+        if case == "message_posted":
+            posted = event.message_posted
+            msg = Message.parse(_pb_to_dict(posted.message)) if posted.HasField("message") else None
+            if msg is None:
+                return
+            actor = None
+            includes = payload.includes
+            if includes is not None and msg.actor_id:
+                u = includes.users.get(msg.actor_id)
+                if u is not None:
+                    actor = User.parse(_pb_to_dict(u))
+            await self._dispatch(
+                BotMessageEvent(bot=self, kind="message", message=msg, actor=actor)
+            )
+        elif case in (
+            "room_created",
+            "room_updated",
+            "room_deleted",
+            "room_archived",
+            "room_unarchived",
+            "room_threading_mode_changed",
+            "user_joined_room",
+            "user_left_room",
+        ):
+            room = None
+            sub = getattr(event, case, None)
+            if sub is not None and sub.HasField("room"):
+                room = Room.parse(_pb_to_dict(sub.room))
+            await self._dispatch(BotRoomEvent(bot=self, kind="room", room=room, detail=case))
+
+
+def _pb_to_dict(msg: Any) -> dict[str, Any]:
+    """Best-effort protobuf -> camelCase dict for the dataclass parsers."""
+    from chattolib._transport import pb_to_dict
+
+    return pb_to_dict(msg)

+ 217 - 0
tests/test_bot.py

@@ -0,0 +1,217 @@
+"""Tests for the chattolib bot framework (chattolib.bot).
+
+The realtime WebSocket handshake is not exercised here (that is the job of
+the live integration test); instead we feed synthetic realtime frames through
+the dispatcher and assert the typed events and the convenience verbs.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+from chattolib._pb.chatto.api.v1 import (
+    messages_pb2,
+    presence_pb2,
+    rooms_pb2,
+)
+from chattolib._pb.chatto.realtime.v1 import realtime_pb2
+from chattolib.bot import (
+    Bot,
+    BotMessageEvent,
+    BotPresenceEvent,
+    BotTypingEvent,
+)
+from chattolib.client import ChattoClient
+from chattolib.realtime import _wrap_projection_event
+from chattolib.types import PresenceStatus, Room, User
+
+
+def _bot() -> Bot:
+    client = ChattoClient(token="cht_BK_test", base_url="https://example.test")
+    bot = Bot.from_client(client)
+    bot._user = User(id="u_bot", login="felix_bot", display_name="Felix - the bot", is_bot=True)
+    return bot
+
+
+def _mock(client: ChattoClient, service: str, method: str, response):
+    svc = getattr(client._svc, service)
+    setattr(svc, method, AsyncMock(return_value=response))
+    return getattr(svc, method)
+
+
+# --- verbs ---------------------------------------------------------------
+
+
+async def test_say_posts_message():
+    bot = _bot()
+    resp = messages_pb2.CreateMessageResponse()
+    resp.message.id = "e1"
+    resp.message.room_id = "r1"
+    resp.message.body = "hello"
+    m = _mock(bot.client, "messages", "create_message", resp)
+
+    msg = await bot.say("r1", "hello")
+    assert msg.body == "hello"
+    m.assert_awaited_once()
+
+
+async def test_reply_threads_to_original():
+    bot = _bot()
+    resp = messages_pb2.CreateMessageResponse()
+    resp.message.id = "e2"
+    resp.message.room_id = "r1"
+    resp.message.body = "hi!"
+    resp.message.in_reply_to = "e1"
+    _mock(bot.client, "messages", "create_message", resp)
+
+    target = BotMessageEvent(bot=bot, kind="message", message=resp.message)
+    out = await bot.reply(target, "hi!")
+    assert out.in_reply_to == "e1"
+
+
+async def test_react_and_unreact():
+    bot = _bot()
+    bot.client.add_reaction = AsyncMock(return_value=True)  # type: ignore[method-assign]
+    bot.client.remove_reaction = AsyncMock(return_value=True)  # type: ignore[method-assign]
+    assert await bot.react("r1", "e1", "👍") is True
+    assert await bot.unreact("r1", "e1", "👍") is True
+
+
+async def test_set_presence_and_status():
+    bot = _bot()
+    bot.client.update_presence = AsyncMock(return_value=PresenceStatus.ONLINE)  # type: ignore[method-assign]
+    assert await bot.set_presence(PresenceStatus.ONLINE) is PresenceStatus.ONLINE
+
+    bot.client.update_custom_status = AsyncMock(return_value={"ok": True})  # type: ignore[method-assign]
+    assert await bot.set_status("🛠️", "working") == {"ok": True}
+
+
+async def test_join_and_create_room():
+    bot = _bot()
+    join_resp = rooms_pb2.JoinRoomResponse()
+    join_resp.room.id = "r1"
+    join_resp.room.name = "general"
+    _mock(bot.client, "rooms", "join_room", join_resp)
+    room = await bot.join_room("r1")
+    assert isinstance(room, Room) and room.id == "r1"
+
+    create_resp = rooms_pb2.CreateRoomResponse()
+    create_resp.room.id = "r2"
+    create_resp.room.name = "new"
+    _mock(bot.client, "rooms", "create_room", create_resp)
+    created = await bot.create_room("new", "g1")
+    assert created.name == "new"
+
+
+# --- event dispatch -------------------------------------------------------
+
+
+async def test_dispatch_message_event():
+    bot = _bot()
+    seen: list[BotMessageEvent] = []
+
+    async def on_message(event: BotMessageEvent) -> None:
+        seen.append(event)
+
+    bot.on("message", on_message)
+
+    # Build a projection frame carrying a message_posted timeline event.
+    frame = realtime_pb2.RealtimeProjectionEvent()
+    frame.id = "p1"
+    op = frame.operations.add()
+    op.room_timeline_event_upsert.room_id = "r1"
+    evt = op.room_timeline_event_upsert.event
+    mp = evt.message_posted
+    mp.message.id = "e1"
+    mp.message.room_id = "r1"
+    mp.message.body = "hello @felix_bot"
+    mp.message.actor_id = "u1"
+    mp.message.created_at.FromJsonString("2026-01-01T00:00:00Z")
+
+    await bot._handle_projection(_wrap_projection_event(frame))
+    assert len(seen) == 1
+    assert seen[0].message.body == "hello @felix_bot"
+    assert seen[0].room_id == "r1"
+    assert seen[0].is_mention  # body mentions the bot's login
+
+
+async def test_dispatch_presence_event():
+    bot = _bot()
+    seen: list[BotPresenceEvent] = []
+
+    async def on_presence(event: BotPresenceEvent) -> None:
+        seen.append(event)
+
+    bot.on("presence", on_presence)
+
+    frame = realtime_pb2.RealtimeProjectionEvent()
+    frame.id = "p2"
+    op = frame.operations.add()
+    op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
+
+    await bot._handle_projection(_wrap_projection_event(frame))
+    assert len(seen) == 1
+    assert seen[0].user_id == "u1"
+    assert seen[0].status is PresenceStatus.ONLINE
+
+
+async def test_dispatch_live_typing():
+    bot = _bot()
+    seen: list[BotTypingEvent] = []
+
+    async def on_typing(event: BotTypingEvent) -> None:
+        seen.append(event)
+
+    bot.on("typing", on_typing)
+
+    from chattolib.realtime import RealtimeEvent
+
+    live = RealtimeEvent(
+        id="evt",
+        created_at=None,
+        actor_id="u1",
+        kind="user_typing",
+        payload=realtime_pb2.RealtimeTypingEvent(room_id="r1"),
+        raw=None,
+    )
+    await bot._handle_live(live)
+    assert len(seen) == 1
+    assert seen[0].room_id == "r1"
+
+
+async def test_wildcard_handler_receives_all():
+    bot = _bot()
+    seen: list[str] = []
+
+    async def on_any(event) -> None:
+        seen.append(event.kind)
+
+    bot.on("*", on_any)
+
+    frame = realtime_pb2.RealtimeProjectionEvent()
+    frame.id = "p3"
+    op = frame.operations.add()
+    op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_AWAY
+    await bot._handle_projection(_wrap_projection_event(frame))
+    assert seen == ["presence"]
+
+
+async def test_a_failing_handler_does_not_stop_the_loop():
+    bot = _bot()
+    calls: list[int] = []
+
+    async def bad(event) -> None:
+        raise RuntimeError("boom")
+
+    async def good(event) -> None:
+        calls.append(1)
+
+    bot.on("presence", bad)
+    bot.on("presence", good)
+
+    frame = realtime_pb2.RealtimeProjectionEvent()
+    frame.id = "p4"
+    op = frame.operations.add()
+    op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
+    await bot._handle_projection(_wrap_projection_event(frame))
+    assert calls == [1]  # the good handler still ran after the bad one raised