Selaa lähdekoodia

feat: initial Chatto platform adapter plugin

Hermes Agent 2 viikkoa sitten
commit
ea866d907a

+ 115 - 0
README.md

@@ -0,0 +1,115 @@
+# Hermes Chatto Plugin
+
+A [Chatto](https://github.com/chattocorp/chatto) platform adapter plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Connects your self-hosted Chatto server to Hermes so you can interact with the agent through chat rooms and DMs.
+
+## Features
+
+- **WebSocket realtime** — binary protobuf codec for Chatto's realtime protocol (no protobuf library needed — pure stdlib)
+- **ConnectRPC REST API** — JSON over HTTP for outbound messages, room discovery, user lookup
+- **Thread support** — `GetThreadEvents` for thread replies, auto-thread creation, proper `threadRootEventId` handling
+- **DM support** — DMs always get a response, no threading in DM rooms
+- **Mention filtering** — `require_mention` (default true) with `free_response_channels` for no-tag rooms
+- **Dynamic room discovery** — auto-joins new rooms without gateway restart
+- **Event handling** — `message_posted`, `mention_notification`, `new_dm_notification`, `session_terminated` (force reconnect), `message_retracted`, `user_left_room`, `room_created`, `user_joined_room`
+- **Presence broadcasting** — periodic presence refresh
+- **Notification dismissal** — marks notifications as read after processing
+- **Standalone cron delivery** — works outside the gateway process
+
+## Installation
+
+```bash
+# Clone into your Hermes plugins directory
+git clone https://github.com/jrimmer/hermes-chatto-plugin.git /tmp/chatto-plugin
+
+# Copy the plugin into place
+cp -r /tmp/chatto-plugin/plugins/platforms/chatto ~/.hermes/plugins/platforms/chatto
+```
+
+Or if you're using a profile:
+
+```bash
+cp -r /tmp/chatto-plugin/plugins/platforms/chatto ~/.hermes/profiles/<profile>/plugins/platforms/chatto
+```
+
+## Configuration
+
+Add your Chatto credentials to `~/.hermes/.env`:
+
+```bash
+CHATTO_URL=https://chat.example.com
+CHATTO_LOGIN=max
+CHATTO_PASSWORD=your-password
+CHATTO_HOME_CHANNEL=REljMv5Pgolo6Y9
+```
+
+Then enable the plugin in `~/.hermes/config.yaml`:
+
+```yaml
+plugins:
+  enabled:
+    - platforms/chatto
+
+gateway:
+  platforms:
+    chatto:
+      enabled: true
+      extra:
+        url: https://chat.example.com
+        require_mention: true
+        home_channel: REljMv5Pgolo6Y9
+        free_response_channels: []
+```
+
+Restart the gateway:
+
+```bash
+hermes gateway restart
+```
+
+## Configuration Options
+
+### Environment Variables
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `CHATTO_URL` | Yes | Base URL of your Chatto server |
+| `CHATTO_LOGIN` | Yes | Chatto username |
+| `CHATTO_PASSWORD` | Yes | Chatto password |
+| `CHATTO_HOME_CHANNEL` | No | Room ID for cron/notification delivery (defaults to first watched room) |
+| `CHATTO_CHANNELS` | No | Comma-separated room IDs to watch (default: all joined rooms) |
+| `CHATTO_REQUIRE_MENTION` | No | Only respond to @mentions in rooms (default: true). DMs always respond. |
+| `CHATTO_AUTO_THREAD` | No | Auto-create threads for replies in rooms (default: true) |
+| `CHATTO_FREE_RESPONSE_CHANNELS` | No | Comma-separated room IDs where bot responds without being tagged |
+| `CHATTO_ALLOWED_USERS` | No | Comma-separated Chatto logins allowed to talk to the bot |
+| `CHATTO_ALLOW_ALL_USERS` | No | Allow any Chatto user to talk to the bot (true/false) |
+
+### YAML Config
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `url` | — | Chatto server URL |
+| `require_mention` | `true` | Only respond to @mentions in public rooms |
+| `home_channel` | first room | Room ID for cron delivery |
+| `free_response_channels` | `[]` | Rooms where the bot responds without being tagged |
+| `channels` | all joined | List of room IDs to watch |
+
+## How It Works
+
+The adapter connects to Chatto via two channels:
+
+1. **WebSocket** (`/api/realtime`) — receives realtime events (messages, mentions, DMs, room changes) as binary protobuf frames
+2. **ConnectRPC REST** (`/api/connect/`) — sends messages, fetches room events, joins rooms, manages presence
+
+When a message is received:
+1. The WebSocket frame is decoded as a protobuf envelope
+2. If it's a `message_posted` event, the message body is fetched via REST (`GetRoomEvents` or `GetThreadEvents` for thread replies)
+3. The message is dispatched to the Hermes agent
+4. The agent's response is sent back via `CreateMessage` with proper threading
+
+## Tested Against
+
+Chatto v0.4.19. Protocol version 1 (binary protobuf over WebSocket).
+
+## License
+
+MIT

+ 2924 - 0
plugins/platforms/chatto/adapter.py

@@ -0,0 +1,2924 @@
+"""
+Chatto Platform Adapter for Hermes Agent.
+
+A plugin-based gateway adapter that connects to a Chatto server
+(self-hosted team chat) and relays messages to/from the Hermes agent.
+
+The adapter uses the Chatto REST/ConnectRPC API (JSON over HTTP) for
+outbound (CreateMessage) and the Chatto WebSocket realtime protocol
+(binary protobuf) for inbound message delivery.
+
+Configuration in config.yaml::
+
+    gateway:
+      platforms:
+        chatto:
+          enabled: true
+          extra:
+            url: https://chat.lacy.casa
+            channels:                  # room IDs to watch (empty = all joined)
+              - REljMv5Pgolo6Y9
+            home_channel: REljMv5Pgolo6Y9
+            require_mention: true      # only respond to @mentions in rooms
+            allowed_users: []          # empty = allow all
+            allow_all_users: true
+
+Or via environment variables (overrides config.yaml):
+    CHATTO_URL, CHATTO_LOGIN, CHATTO_PASSWORD (secrets in ~/.hermes/.env),
+    CHATTO_CHANNELS, CHATTO_HOME_CHANNEL,
+    CHATTO_REQUIRE_MENTION, CHATTO_ALLOWED_USERS, CHATTO_ALLOW_ALL_USERS
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+import logging
+import mimetypes
+import os
+import ssl
+import urllib.error
+import urllib.request
+from collections import OrderedDict
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import urlsplit, urlunsplit
+
+logger = logging.getLogger(__name__)
+
+from gateway.platforms.base import (
+    BasePlatformAdapter,
+    SendResult,
+    MessageEvent,
+    MessageType,
+    ProcessingOutcome,
+)
+from gateway.config import Platform
+
+# --------------------------------------------------------------------------- #
+# Constants
+# --------------------------------------------------------------------------- #
+
+_HTTP_TIMEOUT = 30.0
+_CONNECT_RPC_VERSION = "1"
+_MAX_MESSAGE_LENGTH = 10000
+_SEEN_CAP = 500
+
+# WebSocket / realtime protocol
+_REALTIME_PROTOCOL_VERSION = 1  # v0.4.19 supports v1; v2 reserved for future
+_WS_PATH = "/api/realtime"
+_WS_AUTH_TIMEOUT = 20.0
+_WS_MAX_MESSAGE_BYTES = 4_000_000
+_WS_PING_INTERVAL = 30.0  # seconds between client ping frames
+_WS_RECONNECT_INITIAL_BACKOFF = 1.0
+_WS_RECONNECT_MAX_BACKOFF = 30.0
+
+# ConnectRPC service paths (relative to base URL) — used for REST calls
+_RPC_BASE = "/api/connect"
+_PATH_LIST_ROOMS = f"{_RPC_BASE}/chatto.api.v1.RoomDirectoryService/ListRooms"
+_PATH_JOIN_ROOM = f"{_RPC_BASE}/chatto.api.v1.RoomService/JoinRoom"
+_PATH_GET_ROOM_EVENTS = f"{_RPC_BASE}/chatto.api.v1.RoomService/GetRoomEvents"
+_PATH_GET_THREAD_EVENTS = f"{_RPC_BASE}/chatto.api.v1.ThreadService/GetThreadEvents"
+_PATH_CREATE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/CreateMessage"
+_PATH_GET_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/GetMessage"
+_PATH_GET_VIEWER = f"{_RPC_BASE}/chatto.api.v1.ViewerService/GetViewer"
+_PATH_UPDATE_TYPING = f"{_RPC_BASE}/chatto.api.v1.RoomService/UpdateTypingIndicator"
+_PATH_START_DM = f"{_RPC_BASE}/chatto.api.v1.RoomService/StartDM"
+_PATH_LIST_MEMBERS = f"{_RPC_BASE}/chatto.api.v1.RoomService/ListMembers"
+
+# Message lifecycle: reactions, editing, deletion
+_PATH_ADD_REACTION = f"{_RPC_BASE}/chatto.api.v1.MessageService/AddReaction"
+_PATH_REMOVE_REACTION = f"{_RPC_BASE}/chatto.api.v1.MessageService/RemoveReaction"
+_PATH_UPDATE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/UpdateMessage"
+_PATH_DELETE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/DeleteMessage"
+
+# Chunked asset upload
+_PATH_CREATE_UPLOAD = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/CreateUpload"
+_PATH_UPLOAD_CHUNK = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/UploadChunk"
+_PATH_COMPLETE_UPLOAD = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/CompleteUpload"
+
+# Read state management
+_PATH_MARK_ROOM_READ = f"{_RPC_BASE}/chatto.api.v1.RoomService/MarkRoomAsRead"
+_PATH_MARK_THREAD_READ = f"{_RPC_BASE}/chatto.api.v1.ThreadService/MarkThreadAsRead"
+
+# Thread following
+_PATH_FOLLOW_THREAD = f"{_RPC_BASE}/chatto.api.v1.ThreadService/FollowThread"
+
+# Room creation
+_PATH_CREATE_ROOM = f"{_RPC_BASE}/chatto.api.v1.RoomService/CreateRoom"
+
+# Notification dismissal
+_PATH_DISMISS_ALL_NOTIFICATIONS = f"{_RPC_BASE}/chatto.api.v1.NotificationService/DismissAllNotifications"
+_PATH_DISMISS_NOTIFICATION = f"{_RPC_BASE}/chatto.api.v1.NotificationService/DismissNotification"
+
+# Member directory — user lookup and mention resolution
+_PATH_LIST_USERS = f"{_RPC_BASE}/chatto.api.v1.UserService/ListUsers"
+_PATH_GET_USER = f"{_RPC_BASE}/chatto.api.v1.UserService/GetUser"
+_PATH_BATCH_GET_USERS = f"{_RPC_BASE}/chatto.api.v1.UserService/BatchGetUsers"
+
+# Presence broadcasting — online/away/DND status
+_PATH_UPDATE_PRESENCE = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/UpdatePresence"
+
+# Custom status messages
+_PATH_UPDATE_CUSTOM_STATUS = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/UpdateCustomStatus"
+_PATH_DELETE_CUSTOM_STATUS = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/DeleteCustomStatus"
+
+# Presence status int mapping (Chatto API)
+_PRESENCE_STATUS_MAP: Dict[str, int] = {
+    "online": 1,
+    "away": 2,
+    "dnd": 3,
+    "do_not_disturb": 3,
+}
+
+# Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
+_EMOJI_TO_SHORTCODE: Dict[str, str] = {
+    "👍": "thumbsup",
+    "👎": "thumbsdown",
+    "❤️": "heart",
+    "❤": "heart",
+    "✅": "white_check_mark",
+    "❌": "x",
+    "👀": "eyes",
+    "🎉": "tada",
+    "😂": "joy",
+    "🚀": "rocket",
+    "🔥": "fire",
+    "💯": "100",
+    "🤔": "thinking",
+    "👏": "clap",
+    "🙏": "pray",
+    "😅": "sweat_smile",
+    "😴": "sleeping",
+    "⏳": "hourglass",
+}
+
+# Chunk size for asset uploads (256 KB)
+_UPLOAD_CHUNK_SIZE = 256 * 1024
+
+
+# --------------------------------------------------------------------------- #
+# Minimal Protobuf Encoder/Decoder (stdlib only)
+# --------------------------------------------------------------------------- #
+#
+# Implements just enough of the protobuf binary format to encode/decode
+# the Chatto realtime protocol frames.  No external protobuf library needed.
+#
+# Wire types:
+#   0 = varint
+#   2 = length-delimited (bytes/string/submessage)
+#
+# Field tag = (field_number << 3) | wire_type
+
+def _encode_varint(value: int) -> bytes:
+    """Encode an unsigned integer as a protobuf varint."""
+    if value < 0:
+        # Treat as unsigned 64-bit
+        value &= (1 << 64) - 1
+    out = bytearray()
+    while value > 0x7F:
+        out.append((value & 0x7F) | 0x80)
+        value >>= 7
+    out.append(value & 0x7F)
+    return bytes(out)
+
+
+def _decode_varint(data: bytes, offset: int) -> Tuple[int, int]:
+    """Decode a varint from data at offset. Returns (value, new_offset)."""
+    result = 0
+    shift = 0
+    while True:
+        if offset >= len(data):
+            raise ValueError("Truncated varint")
+        byte = data[offset]
+        offset += 1
+        result |= (byte & 0x7F) << shift
+        if not (byte & 0x80):
+            break
+        shift += 7
+        if shift >= 64:
+            raise ValueError("Varint too long")
+    return result, offset
+
+
+def _encode_tag(field_number: int, wire_type: int) -> bytes:
+    """Encode a protobuf field tag."""
+    return _encode_varint((field_number << 3) | wire_type)
+
+
+def _encode_field_varint(field_number: int, value: int) -> bytes:
+    """Encode a varint field."""
+    return _encode_tag(field_number, 0) + _encode_varint(value)
+
+
+def _encode_field_bytes(field_number: int, value: bytes) -> bytes:
+    """Encode a length-delimited field (bytes/string/submessage)."""
+    return _encode_tag(field_number, 2) + _encode_varint(len(value)) + value
+
+
+def _encode_field_string(field_number: int, value: str) -> bytes:
+    """Encode a string field."""
+    return _encode_field_bytes(field_number, value.encode("utf-8"))
+
+
+def _encode_submessage(field_number: int, submessage: bytes) -> bytes:
+    """Encode a submessage field (length-delimited)."""
+    return _encode_field_bytes(field_number, submessage)
+
+
+def _decode_fields(data: bytes) -> Dict[int, List[Any]]:
+    """Decode all fields from a protobuf message.
+
+    Returns a dict mapping field_number -> list of values.
+    For varint fields, value is int.
+    For length-delimited fields, value is bytes (raw).
+    """
+    fields: Dict[int, List[Any]] = {}
+    offset = 0
+    while offset < len(data):
+        tag, offset = _decode_varint(data, offset)
+        field_number = tag >> 3
+        wire_type = tag & 0x07
+        if wire_type == 0:  # varint
+            value, offset = _decode_varint(data, offset)
+            fields.setdefault(field_number, []).append(value)
+        elif wire_type == 2:  # length-delimited
+            length, offset = _decode_varint(data, offset)
+            if offset + length > len(data):
+                raise ValueError("Truncated length-delimited field")
+            value = data[offset:offset + length]
+            offset += length
+            fields.setdefault(field_number, []).append(value)
+        elif wire_type == 1:  # 64-bit
+            if offset + 8 > len(data):
+                raise ValueError("Truncated 64-bit field")
+            value = data[offset:offset + 8]
+            offset += 8
+            fields.setdefault(field_number, []).append(value)
+        elif wire_type == 5:  # 32-bit
+            if offset + 4 > len(data):
+                raise ValueError("Truncated 32-bit field")
+            value = data[offset:offset + 4]
+            offset += 4
+            fields.setdefault(field_number, []).append(value)
+        else:
+            raise ValueError(f"Unknown wire type {wire_type} for field {field_number}")
+    return fields
+
+
+def _get_first(fields: Dict[int, List[Any]], field_number: int, default: Any = None) -> Any:
+    """Get the first value for a field number, or default."""
+    values = fields.get(field_number)
+    if values:
+        return values[0]
+    return default
+
+
+def _get_all(fields: Dict[int, List[Any]], field_number: int) -> List[Any]:
+    """Get all values for a field number."""
+    return fields.get(field_number, [])
+
+
+# --------------------------------------------------------------------------- #
+# Protobuf message helpers for Chatto realtime protocol
+# --------------------------------------------------------------------------- #
+
+def _encode_client_hello(bearer_token: str) -> bytes:
+    """Encode RealtimeClientHello {
+        uint32 protocol_version = 1;  // v0.4.19 uses v1
+        optional string bearer_token = 2;
+    }"""
+    msg = _encode_field_varint(1, _REALTIME_PROTOCOL_VERSION)
+    if bearer_token:
+        msg += _encode_field_string(2, bearer_token)
+    return msg
+
+
+def _encode_subscribe_events(
+    resume_cursor: Optional[str] = None,
+    retained_room_ids: Optional[List[str]] = None,
+) -> bytes:
+    """Encode RealtimeSubscribeEvents {
+        optional string resume_cursor = 1;
+        repeated string retained_room_ids = 2;
+    }"""
+    msg = b""
+    if resume_cursor:
+        msg += _encode_field_string(1, resume_cursor)
+    if retained_room_ids:
+        for rid in retained_room_ids:
+            msg += _encode_field_string(2, rid)
+    return msg
+
+
+def _encode_ping() -> bytes:
+    """Encode RealtimePing (empty message)."""
+    return b""
+
+
+def _encode_client_frame_hello(hello_bytes: bytes) -> bytes:
+    """Encode RealtimeClientFrame { oneof frame { RealtimeClientHello hello = 1; } }"""
+    return _encode_submessage(1, hello_bytes)
+
+
+def _encode_client_frame_subscribe(subscribe_bytes: bytes) -> bytes:
+    """Encode RealtimeClientFrame { oneof frame { RealtimeSubscribeEvents subscribe_events = 2; } }"""
+    return _encode_submessage(2, subscribe_bytes)
+
+
+def _encode_client_frame_ping(ping_bytes: bytes) -> bytes:
+    """Encode RealtimeClientFrame { oneof frame { RealtimePing ping = 3; } }"""
+    return _encode_submessage(3, ping_bytes)
+
+
+def _decode_server_frame(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeServerFrame and identify which oneof variant is set.
+
+    Returns a dict like:
+        {"type": "hello", "data": <decoded RealtimeServerHello bytes>}
+        {"type": "subscribed", "data": <decoded RealtimeSubscribed bytes>}
+        {"type": "event", "data": <decoded RealtimeEventEnvelope bytes>}
+        {"type": "heartbeat", "data": <raw bytes>}
+        {"type": "error", "data": <decoded RealtimeError bytes>}
+        {"type": "close", "data": <decoded RealtimeClose bytes>}
+        {"type": "pong", "data": <raw bytes>}
+        {"type": "caught_up", "data": <raw bytes>}
+        {"type": "projection_event", "data": <decoded RealtimeProjectionEvent bytes>}
+        {"type": "unknown", "data": None}
+    """
+    fields = _decode_fields(data)
+    # oneof frame: only one of these fields is set
+    # field 1 = hello, 2 = subscribed, 3 = event, 4 = heartbeat,
+    # 5 = error, 6 = close, 7 = pong, 8 = caught_up, 9 = projection_event
+    type_map = {
+        1: "hello",
+        2: "subscribed",
+        3: "event",
+        4: "heartbeat",
+        5: "error",
+        6: "close",
+        7: "pong",
+        8: "caught_up",
+        9: "projection_event",
+    }
+    for field_num, type_name in type_map.items():
+        if field_num in fields:
+            return {"type": type_name, "data": fields[field_num][0]}
+    return {"type": "unknown", "data": None}
+
+
+def _decode_projection_event(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeProjectionEvent {
+        string id = 1;
+        google.protobuf.Timestamp created_at = 2;
+        optional string actor_id = 3;
+        optional string resume_cursor = 4;
+        repeated RealtimeProjectionOperation operations = 5;
+    }
+
+    Returns dict with keys: id, created_at (ISO str), actor_id, resume_cursor,
+    operations (list of decoded operation dicts).
+    """
+    fields = _decode_fields(data)
+    event_id = _get_first(fields, 1, b"")
+    if isinstance(event_id, bytes):
+        event_id = event_id.decode("utf-8", errors="replace")
+
+    created_at = ""
+    ts_bytes = _get_first(fields, 2)
+    if isinstance(ts_bytes, bytes):
+        created_at = _decode_timestamp(ts_bytes)
+
+    actor_id_raw = _get_first(fields, 3)
+    actor_id = actor_id_raw.decode("utf-8", errors="replace") if isinstance(actor_id_raw, bytes) else ""
+
+    resume_cursor_raw = _get_first(fields, 4)
+    resume_cursor = resume_cursor_raw.decode("utf-8", errors="replace") if isinstance(resume_cursor_raw, bytes) else ""
+
+    operations = []
+    for op_bytes in _get_all(fields, 5):
+        if isinstance(op_bytes, bytes):
+            operations.append(_decode_projection_operation(op_bytes))
+
+    return {
+        "id": event_id,
+        "created_at": created_at,
+        "actor_id": actor_id,
+        "resume_cursor": resume_cursor,
+        "operations": operations,
+    }
+
+
+def _decode_projection_operation(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeProjectionOperation (oneof).
+
+    We only care about room_timeline_event_upsert (field 10).
+
+    Returns dict like:
+        {"type": "room_timeline_event_upsert", "room_id": ..., "event": {...}, "includes": {...}}
+        {"type": "unknown", "field": N}
+    """
+    fields = _decode_fields(data)
+
+    # field 10 = room_timeline_event_upsert
+    if 10 in fields:
+        upsert_bytes = fields[10][0]
+        if isinstance(upsert_bytes, bytes):
+            return _decode_room_timeline_event_upsert(upsert_bytes)
+
+    # Find which field is set for debugging
+    for field_num in fields:
+        if field_num != 10:
+            type_names = {
+                1: "room_upsert",
+                2: "room_delete",
+                3: "room_member_upsert",
+                4: "room_member_delete",
+                5: "room_typing_upsert",
+                6: "room_typing_delete",
+                7: "room_read_state_upsert",
+                8: "room_read_state_delete",
+                9: "room_subscription_upsert",
+                10: "room_timeline_event_upsert",
+                11: "room_timeline_event_delete",
+            }
+            return {"type": type_names.get(field_num, f"field_{field_num}"), "field": field_num}
+
+    return {"type": "empty"}
+
+
+def _decode_room_timeline_event_upsert(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeProjectionRoomTimelineEventUpsert {
+        string room_id = 1;
+        chatto.api.v1.RoomTimelineEvent event = 2;
+        chatto.api.v1.RoomTimelineIncludes includes = 3;
+    }"""
+    fields = _decode_fields(data)
+
+    room_id_raw = _get_first(fields, 1)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+
+    event_dict = {}
+    event_bytes = _get_first(fields, 2)
+    if isinstance(event_bytes, bytes):
+        event_dict = _decode_room_timeline_event(event_bytes)
+
+    return {
+        "type": "room_timeline_event_upsert",
+        "room_id": room_id,
+        "event": event_dict,
+    }
+
+
+def _decode_room_timeline_event(data: bytes) -> Dict[str, Any]:
+    """Decode RoomTimelineEvent from the API proto.
+
+    RoomTimelineEvent {
+        string id = 1;
+        google.protobuf.Timestamp created_at = 2;
+        string room_id = 3;
+        chatto.api.v1.RoomTimelineEventKind kind = 4;  // enum as varint
+        chatto.api.v1.RoomTimelineEventMessagePosted message_posted = 5;
+        // ... other event kinds (member_joined, etc.) at higher field numbers
+    }
+
+    RoomTimelineEventMessagePosted {
+        chatto.api.v1.Message message = 1;
+    }
+
+    Message {
+        string id = 1;
+        string room_id = 2;
+        string actor_id = 3;
+        string body = 4;
+        google.protobuf.Timestamp created_at = 5;
+        optional string actor_login = 6;
+        optional string actor_display_name = 7;
+        optional chatto.api.v1.MessageThread thread = 8;
+        // ... other fields
+    }
+
+    MessageThread {
+        string thread_root_event_id = 1;
+    }
+    """
+    fields = _decode_fields(data)
+
+    event_id_raw = _get_first(fields, 1)
+    event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
+
+    created_at = ""
+    ts_bytes = _get_first(fields, 2)
+    if isinstance(ts_bytes, bytes):
+        created_at = _decode_timestamp(ts_bytes)
+
+    room_id_raw = _get_first(fields, 3)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+
+    kind = _get_first(fields, 4, 0)  # enum as varint int
+
+    # field 5 = message_posted (submessage)
+    message = {}
+    posted_bytes = _get_first(fields, 5)
+    if isinstance(posted_bytes, bytes):
+        message = _decode_message_posted(posted_bytes)
+
+    return {
+        "id": event_id,
+        "createdAt": created_at,
+        "roomId": room_id,
+        "kind": kind,
+        "messagePosted": message if message else None,
+    }
+
+
+def _decode_message_posted(data: bytes) -> Dict[str, Any]:
+    """Decode RoomTimelineEventMessagePosted {
+        chatto.api.v1.Message message = 1;
+    }"""
+    fields = _decode_fields(data)
+    msg_bytes = _get_first(fields, 1)
+    if isinstance(msg_bytes, bytes):
+        return {"message": _decode_message(msg_bytes)}
+    return {"message": {}}
+
+
+def _decode_message(data: bytes) -> Dict[str, Any]:
+    """Decode Message proto.
+
+    Message {
+        string id = 1;
+        string room_id = 2;
+        string actor_id = 3;
+        string body = 4;
+        google.protobuf.Timestamp created_at = 5;
+        optional string actor_login = 6;
+        optional string actor_display_name = 7;
+        optional MessageThread thread = 8;
+    }
+    """
+    fields = _decode_fields(data)
+
+    def _str_field(fnum: int) -> str:
+        val = _get_first(fields, fnum)
+        if isinstance(val, bytes):
+            return val.decode("utf-8", errors="replace")
+        return ""
+
+    msg_id = _str_field(1)
+    room_id = _str_field(2)
+    actor_id = _str_field(3)
+    body = _str_field(4)
+
+    created_at = ""
+    ts_bytes = _get_first(fields, 5)
+    if isinstance(ts_bytes, bytes):
+        created_at = _decode_timestamp(ts_bytes)
+
+    actor_login = _str_field(6)
+    actor_display_name = _str_field(7)
+
+    thread = {}
+    thread_bytes = _get_first(fields, 8)
+    if isinstance(thread_bytes, bytes):
+        thread = _decode_thread(thread_bytes)
+
+    return {
+        "id": msg_id,
+        "roomId": room_id,
+        "actorId": actor_id,
+        "body": body,
+        "createdAt": created_at,
+        "actorLogin": actor_login,
+        "actorDisplayName": actor_display_name,
+        "thread": thread,
+    }
+
+
+def _decode_thread(data: bytes) -> Dict[str, Any]:
+    """Decode MessageThread {
+        string thread_root_event_id = 1;
+    }"""
+    fields = _decode_fields(data)
+    thread_root = _get_first(fields, 1)
+    if isinstance(thread_root, bytes):
+        thread_root = thread_root.decode("utf-8", errors="replace")
+    else:
+        thread_root = ""
+    return {"threadRootEventId": thread_root}
+
+
+def _decode_timestamp(data: bytes) -> str:
+    """Decode google.protobuf.Timestamp {
+        int64 seconds = 1;
+        int32 nanos = 2;
+    }
+    Returns ISO 8601 string.
+    """
+    fields = _decode_fields(data)
+    seconds = _get_first(fields, 1, 0)
+    nanos = _get_first(fields, 2, 0)
+
+    # Handle signed int64 (protobuf varints are unsigned, but int64 values
+    # may be negative — reinterpret)
+    if isinstance(seconds, int) and seconds >= (1 << 63):
+        seconds -= (1 << 64)
+
+    if not seconds:
+        return ""
+
+    # Convert to ISO format
+    try:
+        dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
+        # Add nanosecond precision if present
+        if nanos:
+            # microsecond precision is the best Python supports
+            micros = nanos // 1000
+            dt = dt.replace(microsecond=micros % 1000000)
+        return dt.isoformat().replace("+00:00", "Z")
+    except (OSError, ValueError, OverflowError):
+        return ""
+
+
+def _decode_event_envelope(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeEventEnvelope for transient events.
+
+    RealtimeEventEnvelope {
+        string id = 1;
+        google.protobuf.Timestamp created_at = 2;
+        optional string actor_id = 3;
+        oneof event {
+            RealtimeMessagePostedEvent message_posted = 10;
+            RealtimeMessageEditedEvent message_edited = 11;
+            ...
+            RealtimeMentionNotificationEvent mention_notification = 88;
+            RealtimeNewDirectMessageNotificationEvent new_direct_message_notification = 89;
+        }
+    }
+    """
+    fields = _decode_fields(data)
+
+    event_id_raw = _get_first(fields, 1)
+    event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
+
+    created_at = ""
+    ts_bytes = _get_first(fields, 2)
+    if isinstance(ts_bytes, bytes):
+        created_at = _decode_timestamp(ts_bytes)
+
+    actor_id = ""
+    actor_raw = _get_first(fields, 3)
+    if isinstance(actor_raw, bytes):
+        actor_id = actor_raw.decode("utf-8", errors="replace")
+
+    # Check for message_posted (field 10), mention_notification (field 88),
+    # or new_direct_message_notification (field 89)
+    event_type = "unknown"
+    event_data = {}
+    logger.info("Chatto WS: event envelope fields: %s", list(fields.keys()))
+    if 10 in fields:
+        event_type = "message_posted"
+        raw = fields[10][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_message_posted_event(raw)
+    elif 11 in fields:
+        event_type = "message_edited"
+        raw = fields[11][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_message_edited_event(raw)
+    elif 12 in fields:
+        event_type = "message_retracted"
+        raw = fields[12][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_message_retracted_event(raw)
+    elif 46 in fields:
+        event_type = "user_left_room"
+        raw = fields[46][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_room_event(raw)
+    elif 90 in fields:
+        event_type = "session_terminated"
+        raw = fields[90][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_session_terminated_event(raw)
+    elif 40 in fields:
+        event_type = "room_created"
+        raw = fields[40][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_room_event(raw)
+    elif 45 in fields:
+        event_type = "user_joined_room"
+        raw = fields[45][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_room_event(raw)
+    elif 88 in fields:
+        event_type = "mention_notification"
+        raw = fields[88][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_mention_notification(raw)
+    elif 89 in fields:
+        event_type = "new_direct_message_notification"
+        raw = fields[89][0]
+        if isinstance(raw, bytes):
+            event_data = _decode_dm_notification(raw)
+
+    return {
+        "id": event_id,
+        "createdAt": created_at,
+        "actorId": actor_id,
+        "type": event_type,
+        "data": event_data,
+    }
+
+def _decode_message_posted_event(data: bytes) -> Dict[str, Any]:
+    """Decode a RealtimeMessagePostedEvent.
+
+    RealtimeMessagePostedEvent {
+        string room_id = 1;
+        string message_event_id = 2;
+        optional string thread_root_event_id = 3;
+    }
+    """
+    fields = _decode_fields(data)
+    room_id_raw = _get_first(fields, 1)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+    event_id_raw = _get_first(fields, 2)
+    message_event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
+    thread_root_raw = _get_first(fields, 3)
+    thread_root_event_id = thread_root_raw.decode("utf-8", errors="replace") if isinstance(thread_root_raw, bytes) else ""
+    return {
+        "roomId": room_id,
+        "messageEventId": message_event_id,
+        "threadRootEventId": thread_root_event_id,
+    }
+
+
+def _decode_mention_notification(data: bytes) -> Dict[str, Any]:
+    """Decode a mention notification.
+
+    MentionNotification {
+        string room_id = 1;
+        string event_id = 2;
+        // ... other fields
+    }
+    """
+    fields = _decode_fields(data)
+    room_id_raw = _get_first(fields, 1)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+    event_id_raw = _get_first(fields, 2)
+    event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
+    return {"roomId": room_id, "eventId": event_id}
+
+
+def _decode_dm_notification(data: bytes) -> Dict[str, Any]:
+    """Decode a new direct message notification.
+
+    NewDirectMessageNotification {
+        string room_id = 1;
+        string event_id = 2;
+        // ... other fields
+    }
+    """
+    fields = _decode_fields(data)
+    room_id_raw = _get_first(fields, 1)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+    event_id_raw = _get_first(fields, 2)
+    event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
+    return {"roomId": room_id, "eventId": event_id}
+
+
+def _decode_room_event(data: bytes) -> Dict[str, Any]:
+    """Decode a RealtimeRoomEvent.
+
+    RealtimeRoomEvent {
+        string room_id = 1;
+    }
+    """
+    fields = _decode_fields(data)
+    room_id_raw = _get_first(fields, 1)
+    room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
+    return {"roomId": room_id}
+
+
+def _decode_message_edited_event(data: bytes) -> Dict[str, Any]:
+    """Decode a RealtimeMessageEditedEvent.
+
+    RealtimeMessageEditedEvent {
+        string room_id = 1;
+        string message_event_id = 2;
+    }
+    """
+    fields = _decode_fields(data)
+    room_id = _get_first(fields, 1)
+    room_id = room_id.decode("utf-8", errors="replace") if isinstance(room_id, bytes) else ""
+    event_id = _get_first(fields, 2)
+    message_event_id = event_id.decode("utf-8", errors="replace") if isinstance(event_id, bytes) else ""
+    return {"roomId": room_id, "messageEventId": message_event_id}
+
+
+def _decode_message_retracted_event(data: bytes) -> Dict[str, Any]:
+    """Decode a RealtimeMessageRetractedEvent.
+
+    RealtimeMessageRetractedEvent {
+        string room_id = 1;
+        string message_event_id = 2;
+        optional string reason = 3;
+    }
+    """
+    fields = _decode_fields(data)
+    room_id = _get_first(fields, 1)
+    room_id = room_id.decode("utf-8", errors="replace") if isinstance(room_id, bytes) else ""
+    event_id = _get_first(fields, 2)
+    message_event_id = event_id.decode("utf-8", errors="replace") if isinstance(event_id, bytes) else ""
+    reason_raw = _get_first(fields, 3)
+    reason = reason_raw.decode("utf-8", errors="replace") if isinstance(reason_raw, bytes) else ""
+    return {"roomId": room_id, "messageEventId": message_event_id, "reason": reason}
+
+
+def _decode_session_terminated_event(data: bytes) -> Dict[str, Any]:
+    """Decode a RealtimeSessionTerminatedEvent.
+
+    RealtimeSessionTerminatedEvent {
+        string reason = 1;
+    }
+    """
+    fields = _decode_fields(data)
+    reason_raw = _get_first(fields, 1)
+    reason = reason_raw.decode("utf-8", errors="replace") if isinstance(reason_raw, bytes) else ""
+    return {"reason": reason}
+
+
+def _decode_server_hello(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeServerHello.
+
+    RealtimeServerHello {
+        uint32 protocol_version = 1;
+        // ... other fields
+    }
+    """
+    fields = _decode_fields(data)
+    protocol_version = _get_first(fields, 1, 0)
+    return {"protocolVersion": protocol_version}
+
+
+def _decode_error(data: bytes) -> Dict[str, Any]:
+    """Decode RealtimeError {
+        string message = 1;
+        uint32 code = 2;
+    }"""
+    fields = _decode_fields(data)
+    msg_raw = _get_first(fields, 1)
+    message = msg_raw.decode("utf-8", errors="replace") if isinstance(msg_raw, bytes) else ""
+    code = _get_first(fields, 2, 0)
+    return {"message": message, "code": code}
+
+
+# --------------------------------------------------------------------------- #
+# HTTP helpers
+# --------------------------------------------------------------------------- #
+
+def _ssl_context() -> ssl.SSLContext:
+    """Create a default SSL context that verifies certificates."""
+    return ssl.create_default_context()
+
+
+def _rpc_request(
+    base_url: str,
+    path: str,
+    token: Optional[str],
+    body: dict,
+) -> Tuple[int, dict]:
+    """Make a ConnectRPC JSON POST request. Returns (status_code, response_dict)."""
+    url = base_url.rstrip("/") + path
+    headers = {
+        "Content-Type": "application/json",
+        "Connect-Protocol-Version": _CONNECT_RPC_VERSION,
+    }
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+    data = json.dumps(body).encode("utf-8")
+    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+    ctx = _ssl_context()
+    try:
+        with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
+            raw = resp.read().decode("utf-8")
+            return resp.status, json.loads(raw) if raw else {}
+    except urllib.error.HTTPError as e:
+        raw = e.read().decode("utf-8", errors="replace")
+        try:
+            err_body = json.loads(raw)
+        except (ValueError, json.JSONDecodeError):
+            err_body = {"error": raw}
+        return e.code, err_body
+    except Exception as e:
+        return 0, {"error": str(e)}
+
+
+def _auth_login(base_url: str, login: str, password: str) -> Optional[str]:
+    """Login to Chatto and return the bearer token, or None on failure."""
+    url = base_url.rstrip("/") + "/auth/login"
+    headers = {"Content-Type": "application/json"}
+    data = json.dumps({"login": login, "password": password}).encode("utf-8")
+    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+    ctx = _ssl_context()
+    try:
+        with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
+            raw = resp.read().decode("utf-8")
+            result = json.loads(raw)
+            if result.get("success") and result.get("token"):
+                return str(result["token"])
+            logger.error("Chatto: login response did not include token: %s", raw)
+            return None
+    except Exception as e:
+        logger.error("Chatto: login failed for %s — %s", base_url, e)
+        return None
+
+
+# --------------------------------------------------------------------------- #
+# Adapter
+# --------------------------------------------------------------------------- #
+
+
+class ChattoAdapter(BasePlatformAdapter):
+    """Chatto platform adapter — receives messages via WebSocket realtime,
+    sends via ConnectRPC REST."""
+
+    MAX_MESSAGE_LENGTH = 10000
+    _SPLIT_THRESHOLD = 9900
+    splits_long_messages = True
+
+    def __init__(self, config, **kwargs):
+        platform = Platform("chatto")
+        super().__init__(config=config, platform=platform)
+
+        extra = getattr(config, "extra", {}) or {}
+
+        # --- Configuration (env > config.yaml extra) ---
+        self._base_url = (
+            os.getenv("CHATTO_URL", "").strip()
+            or str(extra.get("url", "")).strip()
+        )
+        self._login = os.getenv("CHATTO_LOGIN", "").strip()
+        self._password = os.getenv("CHATTO_PASSWORD", "").strip()
+
+        raw_channels = os.getenv("CHATTO_CHANNELS", "").strip()
+        if raw_channels:
+            self._channel_ids = [c.strip() for c in raw_channels.split(",") if c.strip()]
+        elif isinstance(extra.get("channels"), list):
+            self._channel_ids = [str(c) for c in extra["channels"]]
+        else:
+            self._channel_ids = []
+
+        self._home_channel = (
+            os.getenv("CHATTO_HOME_CHANNEL", "").strip()
+            or str(extra.get("home_channel", "")).strip()
+        )
+
+        self._require_mention = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
+        if self._require_mention:
+            self._require_mention = self._require_mention in ("true", "1", "yes")
+        else:
+            self._require_mention = bool(extra.get("require_mention", True))
+        # free_response_channels: room IDs where the bot responds without being tagged
+        fr_env = os.getenv("CHATTO_FREE_RESPONSE_CHANNELS", "").strip()
+        if fr_env:
+            self._free_response_channels = set(c.strip() for c in fr_env.split(",") if c.strip())
+        else:
+            self._free_response_channels = set(
+                str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
+            )
+
+        # --- Runtime state ---
+        self._token: Optional[str] = None
+        self._user_id: str = ""
+        self._user_login: str = ""
+        self._user_display: str = ""
+        self._room_names: Dict[str, str] = {}
+        self._room_kinds: Dict[str, str] = {}
+        self._our_thread_roots: set = set()  # thread root event IDs we created
+        self._our_message_ids: set = set()  # message IDs we sent (for thread root detection)
+        self._seen: Dict[str, OrderedDict] = {}  # room_id -> OrderedDict(event_id -> None)
+        self._resume_cursor: Optional[str] = None
+        self._watch_room_ids: List[str] = []
+        self._ws_task: Optional[asyncio.Task] = None
+        self._ws_ready: Optional[asyncio.Event] = None
+        self._ws_active = False
+        self._ws_ref = None  # reference to open websocket for dynamic resubscribe
+
+        # Persistent typing indicator loops per room
+        self._typing_tasks: Dict[str, asyncio.Task] = {}
+
+        # Liveness probe (REST health check)
+        self._liveness_interval_seconds = 60.0
+        self._liveness_failure_threshold = 3
+        self._liveness_task: Optional[asyncio.Task] = None
+
+        # Member directory cache: user_id -> user info dict
+        self._user_cache: Dict[str, dict] = {}
+
+    # ------------------------------------------------------------------ #
+    # Auth
+    # ------------------------------------------------------------------ #
+
+    async def _ensure_token(self) -> bool:
+        """Login if we don't have a token, or re-login on 401."""
+        if self._token:
+            return True
+        if not self._base_url or not self._login or not self._password:
+            logger.error("Chatto: missing configuration (URL, login, or password)")
+            self._set_fatal_error("config_missing", "CHATTO_URL/LOGIN/PASSWORD required", retryable=False)
+            return False
+        loop = asyncio.get_event_loop()
+        token = await loop.run_in_executor(None, _auth_login, self._base_url, self._login, self._password)
+        if not token:
+            self._set_fatal_error("auth_failed", "Chatto login failed", retryable=True)
+            return False
+        self._token = token
+        logger.info("Chatto: logged in as %s", self._login)
+        return True
+
+    async def _relogin(self) -> bool:
+        """Force re-login (token expired)."""
+        self._token = None
+        return await self._ensure_token()
+
+    async def _rpc(self, path: str, body: dict, *, retry: bool = True) -> Tuple[int, dict]:
+        """Make an RPC call with automatic re-login on 401."""
+        if not await self._ensure_token():
+            return 0, {"error": "no token"}
+        loop = asyncio.get_event_loop()
+        status, resp = await loop.run_in_executor(None, _rpc_request, self._base_url, path, self._token, body)
+        if status == 401 and retry:
+            logger.debug("Chatto: got 401, re-logging in")
+            if await self._relogin():
+                status, resp = await loop.run_in_executor(None, _rpc_request, self._base_url, path, self._token, body)
+        return status, resp
+
+    # ------------------------------------------------------------------ #
+    # Connection
+    # ------------------------------------------------------------------ #
+
+    async def connect(self, *, is_reconnect: bool = False) -> bool:
+        """Login, discover rooms, start WebSocket realtime connection."""
+        if not await self._ensure_token():
+            return False
+
+        # Get our own user info
+        status, resp = await self._rpc(_PATH_GET_VIEWER, {})
+        if status != 200:
+            msg = resp.get("message") or resp.get("error") or f"HTTP {status}"
+            logger.error("Chatto: GetViewer failed — %s", msg)
+            self._set_fatal_error("connect_failed", msg, retryable=True)
+            return False
+        user = resp.get("user", {}).get("profile", {})
+        self._user_id = str(user.get("id", ""))
+        self._user_login = str(user.get("login", ""))
+        self._user_display = str(user.get("displayName", ""))
+
+        # Discover rooms
+        status, resp = await self._rpc(_PATH_LIST_ROOMS, {})
+        if status != 200:
+            msg = resp.get("message") or resp.get("error") or f"HTTP {status}"
+            logger.error("Chatto: ListRooms failed — %s", msg)
+            self._set_fatal_error("connect_failed", msg, retryable=True)
+            return False
+
+        rooms = resp.get("rooms", [])
+        all_room_ids = []
+        for entry in rooms:
+            room = entry.get("room", {})
+            rid = str(room.get("id", ""))
+            if not rid:
+                continue
+            name = str(room.get("name", rid))
+            kind = str(room.get("kind", ""))
+            self._room_names[rid] = name
+            self._room_kinds[rid] = kind
+            viewer = entry.get("viewerState", {})
+            is_member = viewer.get("isMember", False)
+            # If user-specified channels, only watch those; otherwise watch all joined rooms
+            if self._channel_ids:
+                if rid in self._channel_ids and not is_member:
+                    await self._join_room(rid)
+                all_room_ids.append(rid)
+            elif is_member:
+                all_room_ids.append(rid)
+
+        if self._channel_ids:
+            watch = list(self._channel_ids)
+        else:
+            watch = all_room_ids
+
+        if not watch:
+            logger.error("Chatto: no rooms to watch (join a room or set CHATTO_CHANNELS)")
+            self._set_fatal_error("config_missing", "no Chatto rooms to watch", retryable=False)
+            return False
+
+        # Ensure we're a member of each watched room
+        for rid in watch:
+            if self._room_kinds.get(rid) != "ROOM_KIND_DM":
+                await self._join_room(rid)
+
+        # Pick home channel
+        if not self._home_channel:
+            self._home_channel = watch[0]
+
+        self._watch_room_ids = watch
+
+        # Initialize seen for each room — seed from REST to avoid replaying history
+        for rid in watch:
+            self._seen[rid] = OrderedDict()
+            await self._seed_room(rid)
+
+        # Start WebSocket realtime connection
+        if not await self._start_websocket():
+            self._set_fatal_error(
+                "ws_connect_failed",
+                "Chatto WebSocket realtime connection failed",
+                retryable=True,
+            )
+            return False
+
+        self._mark_connected()
+        self._start_liveness_probe()
+        logger.info(
+            "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
+            self._base_url,
+            self._user_display or self._user_login,
+            len(watch),
+        )
+
+        # Broadcast online presence so the bot appears online in the member list
+        try:
+            await self.set_presence("online")
+        except Exception:
+            logger.debug("Chatto: set_presence(online) failed on connect", exc_info=True)
+
+        return True
+
+    async def disconnect(self) -> None:
+        """Stop WebSocket, liveness probe, typing tasks, and clear state."""
+        # Broadcast away presence before tearing down
+        try:
+            await self.set_presence("away")
+        except Exception:
+            logger.debug("Chatto: set_presence(away) failed on disconnect", exc_info=True)
+
+        self._mark_disconnected()
+        self._ws_active = False
+
+        # Cancel liveness probe
+        await self._cancel_liveness_task()
+
+        # Cancel all typing tasks
+        for chat_id in list(self._typing_tasks.keys()):
+            await self.stop_typing(chat_id)
+
+        if self._ws_task and not self._ws_task.done():
+            self._ws_task.cancel()
+            try:
+                await self._ws_task
+            except (asyncio.CancelledError, Exception):
+                pass
+            self._ws_task = None
+        self._token = None
+
+    # ------------------------------------------------------------------ #
+    # Liveness probe
+    # ------------------------------------------------------------------ #
+
+    def _start_liveness_probe(self) -> None:
+        """Start the periodic REST health probe."""
+        if (
+            self._liveness_interval_seconds <= 0
+            or self._liveness_failure_threshold <= 0
+        ):
+            return
+        if self._liveness_task and not self._liveness_task.done():
+            return
+        self._liveness_task = asyncio.create_task(self._liveness_loop())
+
+    async def _cancel_liveness_task(self) -> None:
+        """Cancel the liveness probe task."""
+        task = self._liveness_task
+        self._liveness_task = None
+        if task and not task.done():
+            task.cancel()
+            try:
+                await task
+            except (asyncio.CancelledError, Exception):
+                pass
+
+    async def _liveness_loop(self) -> None:
+        """Periodically check if the REST API is alive via ViewerService/GetViewer.
+
+        Also refreshes presence status on each successful probe so the bot
+        stays showing as online — Chatto's presence expires if not refreshed.
+
+        On ``threshold`` consecutive failures, set a fatal error with
+        ``retryable=True`` so the gateway runner rebuilds the adapter.
+        """
+        interval = self._liveness_interval_seconds
+        threshold = self._liveness_failure_threshold
+        failures = 0
+        while self._running:
+            try:
+                await asyncio.sleep(interval)
+            except asyncio.CancelledError:
+                return
+            if not self._running:
+                return
+            try:
+                status, resp = await self._rpc(_PATH_GET_VIEWER, {}, retry=False)
+                if status == 200:
+                    failures = 0
+                    # Refresh presence to keep showing as online
+                    try:
+                        await self.set_presence("online")
+                    except Exception:
+                        logger.debug("Chatto: presence refresh failed", exc_info=True)
+                    continue
+                # Non-200 — count as failure
+                reason = f"HTTP {status}"
+            except asyncio.CancelledError:
+                return
+            except Exception as e:
+                reason = str(e)
+
+            failures += 1
+            logger.warning(
+                "Chatto: liveness probe failed (%s, %d/%d)",
+                reason,
+                failures,
+                threshold,
+            )
+            if failures < threshold:
+                continue
+
+            # Threshold exceeded — force reconnect
+            logger.error(
+                "Chatto: liveness probe failed %d times consecutively; forcing reconnect",
+                failures,
+            )
+            self._set_fatal_error(
+                "chatto_liveness_failed",
+                f"Chatto REST API liveness check failed: {reason}",
+                retryable=True,
+            )
+            # Cancel the WebSocket to trigger reconnect
+            if self._ws_task and not self._ws_task.done():
+                self._ws_task.cancel()
+            return
+
+    async def _join_room(self, room_id: str) -> None:
+        """Join a room if not already a member."""
+        status, resp = await self._rpc(_PATH_JOIN_ROOM, {"roomId": room_id})
+        if status == 200:
+            logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
+        elif status == 403 or (resp.get("code") == "permission_denied"):
+            logger.debug("Chatto: already a member of %s or cannot join", room_id)
+        else:
+            logger.debug("Chatto: join room %s returned %d — %s", room_id, status, resp.get("message", ""))
+
+    async def _seed_room(self, room_id: str) -> None:
+        """Seed high-water mark from the newest events so a restart doesn't replay history."""
+        status, resp = await self._rpc(_PATH_GET_ROOM_EVENTS, {"roomId": room_id})
+        if status != 200:
+            logger.debug("Chatto: seed GetRoomEvents for %s returned %d", room_id, status)
+            return
+        events = resp.get("page", {}).get("events", [])
+        for ev in events:
+            ev_id = str(ev.get("id", ""))
+            if ev_id:
+                self._mark_seen(room_id, ev_id)
+
+    def _mark_seen(self, room_id: str, event_id: str) -> None:
+        seen = self._seen.setdefault(room_id, OrderedDict())
+        seen[event_id] = None
+        while len(seen) > _SEEN_CAP:
+            seen.popitem(last=False)
+
+    def _is_seen(self, room_id: str, event_id: str) -> bool:
+        return event_id in self._seen.get(room_id, {})
+
+    # ------------------------------------------------------------------ #
+    # WebSocket Realtime Transport
+    # ------------------------------------------------------------------ #
+
+    def _websocket_url(self) -> str:
+        """Build the WebSocket URL from the base HTTP URL."""
+        parsed = urlsplit(self._base_url.strip())
+        scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
+        if scheme not in ("ws", "wss") or not parsed.netloc:
+            raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
+        path = parsed.path.rstrip("/") + _WS_PATH
+        return urlunsplit((scheme, parsed.netloc, path, parsed.query, ""))
+
+    async def _start_websocket(self) -> bool:
+        """Start the WebSocket realtime loop. Returns True if handshake succeeds."""
+        try:
+            import websockets  # noqa: F401 (availability probe)
+            self._websocket_url()
+        except Exception as e:
+            logger.error("Chatto: WebSocket transport unavailable (%s)", e)
+            return False
+
+        self._ws_ready = asyncio.Event()
+        self._ws_task = asyncio.create_task(self._websocket_loop())
+        try:
+            await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 10)
+        except (asyncio.TimeoutError, TimeoutError):
+            logger.warning("Chatto: WebSocket did not authenticate in time")
+            self._ws_active = False
+            if self._ws_task and not self._ws_task.done():
+                self._ws_task.cancel()
+                try:
+                    await self._ws_task
+                except asyncio.CancelledError:
+                    pass
+            self._ws_task = None
+            return False
+        return True
+
+    async def _websocket_loop(self) -> None:
+        """Persistent WebSocket connection with bounded reconnect backoff.
+
+        Protocol flow:
+        1. Connect to /api/realtime
+        2. Send RealtimeClientFrame with hello (protocol_version=1, bearer_token)
+        3. Receive RealtimeServerFrame with hello (RealtimeServerHello)
+        4. Send RealtimeClientFrame with subscribe_events (retained_room_ids)
+        5. Receive RealtimeServerFrame with subscribed
+        6. Receive projection_event frames with room_timeline_event_upsert operations
+        7. Also handle transient event frames (mention/DM notifications)
+        8. Send periodic ping frames for keepalive
+        """
+        import websockets
+
+        backoff = _WS_RECONNECT_INITIAL_BACKOFF
+        try:
+            while True:
+                try:
+                    ws_url = self._websocket_url()
+                    extra_headers = {}
+                    # Some WebSocket servers accept auth via header
+                    if self._token:
+                        extra_headers["Authorization"] = f"Bearer {self._token}"
+
+                    async with websockets.connect(
+                        ws_url,
+                        additional_headers=extra_headers if extra_headers else None,
+                        open_timeout=_WS_AUTH_TIMEOUT,
+                        close_timeout=5,
+                        ping_interval=None,  # we send our own protocol-level pings
+                        ping_timeout=None,
+                        max_size=_WS_MAX_MESSAGE_BYTES,
+                    ) as websocket:
+                        # Step 1: Send hello
+                        hello_body = _encode_client_hello(self._token or "")
+                        hello_frame = _encode_client_frame_hello(hello_body)
+                        await websocket.send(hello_frame)
+                        logger.debug("Chatto WS: sent hello (protocol_version=%d)", _REALTIME_PROTOCOL_VERSION)
+
+                        # Step 2: Receive server hello
+                        raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
+                        server_frame = _decode_server_frame(raw)
+                        if server_frame["type"] != "hello":
+                            if server_frame["type"] == "error":
+                                err = _decode_error(server_frame["data"]) if server_frame["data"] else {}
+                                raise ConnectionError(f"Server error during hello: {err.get('message', 'unknown')}")
+                            raise ConnectionError(f"Expected server hello, got {server_frame['type']}")
+                        server_hello = _decode_server_hello(server_frame["data"]) if server_frame["data"] else {}
+                        proto_ver = server_hello.get("protocolVersion", 0)
+                        logger.info("Chatto WS: server hello received (protocol_version=%s)", proto_ver)
+
+                        # Step 3: Send subscribe_events
+                        subscribe_body = _encode_subscribe_events(
+                            resume_cursor=self._resume_cursor,
+                            retained_room_ids=self._watch_room_ids,
+                        )
+                        subscribe_frame = _encode_client_frame_subscribe(subscribe_body)
+                        await websocket.send(subscribe_frame)
+                        logger.debug(
+                            "Chatto WS: sent subscribe_events for %d room(s), cursor=%s",
+                            len(self._watch_room_ids),
+                            self._resume_cursor or "(none)",
+                        )
+
+                        # Step 4: Receive subscribed confirmation
+                        raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
+                        server_frame = _decode_server_frame(raw)
+                        if server_frame["type"] == "error":
+                            err = _decode_error(server_frame["data"]) if server_frame["data"] else {}
+                            raise ConnectionError(f"Server error during subscribe: {err.get('message', 'unknown')}")
+                        if server_frame["type"] not in ("subscribed", "caught_up", "projection_event"):
+                            # Be lenient — some servers may send events immediately
+                            logger.debug("Chatto WS: received %s after subscribe (expected subscribed)", server_frame["type"])
+                            # Process it as an event if it is one
+                            if server_frame["type"] == "projection_event":
+                                await self._handle_projection_event(server_frame["data"])
+                            elif server_frame["type"] == "event":
+                                await self._handle_transient_event(server_frame["data"])
+
+                        self._ws_active = True
+                        if self._ws_ready is not None and not self._ws_ready.is_set():
+                            self._ws_ready.set()
+                        backoff = _WS_RECONNECT_INITIAL_BACKOFF
+                        logger.info("Chatto WS: subscribed, listening for events")
+
+                        # Store websocket reference for dynamic resubscribe
+                        self._ws_ref = websocket
+
+                        # Step 5: Main event loop with ping keepalive
+                        await self._websocket_event_loop(websocket)
+
+                except asyncio.CancelledError:
+                    raise
+                except Exception as e:
+                    self._ws_active = False
+                    if self._ws_ready is not None and not self._ws_ready.is_set():
+                        # Signal failure to connect() waiter
+                        self._ws_ready.set()
+                    logger.warning("Chatto WS: disconnected; retrying in %.1fs: %s", backoff, e)
+                    await asyncio.sleep(backoff)
+                    backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
+        finally:
+            self._ws_active = False
+            self._ws_ref = None
+
+    async def _websocket_event_loop(self, websocket) -> None:
+        """Main event loop: receive frames and send periodic pings."""
+        logger.info("Chatto WS: event loop started, waiting for frames")
+        ping_task = asyncio.create_task(self._ping_loop(websocket))
+        try:
+            async for raw in websocket:
+                logger.info("Chatto WS: received frame (%d bytes)", len(raw) if raw else 0)
+                if isinstance(raw, str):
+                    # Shouldn't happen with binary protobuf, but handle gracefully
+                    logger.debug("Chatto WS: received text frame (unexpected)")
+                    continue
+
+                try:
+                    server_frame = _decode_server_frame(raw)
+                except (ValueError, IndexError) as e:
+                    logger.warning("Chatto WS: failed to decode server frame: %s", e)
+                    continue
+
+                frame_type = server_frame["type"]
+                logger.info("Chatto WS: frame type=%s size=%d", frame_type, len(raw))
+                frame_data = server_frame["data"]
+
+                if frame_type == "projection_event":
+                    await self._handle_projection_event(frame_data)
+                elif frame_type == "event":
+                    await self._handle_transient_event(frame_data)
+                elif frame_type == "heartbeat":
+                    logger.debug("Chatto WS: heartbeat received")
+                elif frame_type == "pong":
+                    logger.debug("Chatto WS: pong received")
+                elif frame_type == "caught_up":
+                    logger.debug("Chatto WS: caught_up received")
+                elif frame_type == "error":
+                    err = _decode_error(frame_data) if frame_data else {}
+                    logger.warning("Chatto WS: server error: %s (code=%s)", err.get("message", "unknown"), err.get("code"))
+                elif frame_type == "close":
+                    msg = ""
+                    if frame_data:
+                        try:
+                            close_fields = _decode_fields(frame_data)
+                            msg_raw = _get_first(close_fields, 1)
+                            if isinstance(msg_raw, bytes):
+                                msg = msg_raw.decode("utf-8", errors="replace")
+                        except Exception:
+                            pass
+                    logger.info("Chatto WS: server sent close: %s", msg or "(no message)")
+                    raise ConnectionError(f"Server closed: {msg}")
+                elif frame_type == "hello":
+                    # Unexpected re-hello, ignore
+                    logger.debug("Chatto WS: unexpected hello frame")
+                elif frame_type == "subscribed":
+                    logger.debug("Chatto WS: re-subscribed confirmation")
+                else:
+                    logger.debug("Chatto WS: unknown frame type %s", frame_type)
+        finally:
+            logger.info("Chatto WS: event loop ended")
+            ping_task.cancel()
+            try:
+                await ping_task
+            except asyncio.CancelledError:
+                pass
+
+    async def _ping_loop(self, websocket) -> None:
+        """Send periodic ping frames for keepalive."""
+        try:
+            while True:
+                await asyncio.sleep(_WS_PING_INTERVAL)
+                ping_body = _encode_ping()
+                ping_frame = _encode_client_frame_ping(ping_body)
+                await websocket.send(ping_frame)
+                logger.info("Chatto WS: ping sent")
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logger.info("Chatto WS: ping loop exited: %s", e)
+            # WebSocket closed or error — exit silently, the main loop will handle reconnect
+            pass
+
+    # ------------------------------------------------------------------ #
+    # Event handling
+    # ------------------------------------------------------------------ #
+
+    async def _handle_projection_event(self, data: bytes) -> None:
+        """Handle a RealtimeProjectionEvent — parse operations for messages."""
+        if not data:
+            return
+
+        try:
+            event = _decode_projection_event(data)
+        except (ValueError, IndexError) as e:
+            logger.warning("Chatto WS: failed to decode projection event: %s", e)
+            return
+
+        # Update resume cursor if provided
+        cursor = event.get("resume_cursor")
+        if cursor:
+            self._resume_cursor = cursor
+
+        operations = event.get("operations", [])
+        for op in operations:
+            if op.get("type") == "room_timeline_event_upsert":
+                await self._handle_timeline_event_upsert(op)
+            # Other operation types (room_upsert, room_member_upsert, etc.) are
+            # not relevant to message delivery — ignore them.
+
+    async def _handle_timeline_event_upsert(self, op: dict) -> None:
+        """Handle a room_timeline_event_upsert operation."""
+        room_id = op.get("room_id", "")
+        event = op.get("event", {})
+        if not event:
+            return
+
+        ev_id = str(event.get("id", ""))
+        if not ev_id:
+            return
+
+        # De-dupe: skip events we've already seen
+        if self._is_seen(room_id, ev_id):
+            return
+        self._mark_seen(room_id, ev_id)
+
+        # Only handle messagePosted events
+        posted = event.get("messagePosted")
+        if not posted:
+            return
+
+        msg = posted.get("message", {})
+        if not msg:
+            return
+
+        await self._dispatch_message(msg, room_id)
+
+    async def _handle_transient_event(self, data: bytes) -> None:
+        """Handle a transient RealtimeEventEnvelope (message_posted, mentions, DMs).
+
+        These are signal-only events — they contain room_id and event_id but NOT
+        the message body. We fetch the actual message via REST as a fallback.
+        """
+        if not data:
+            return
+
+        try:
+            envelope = _decode_event_envelope(data)
+        except (ValueError, IndexError) as e:
+            logger.warning("Chatto WS: failed to decode transient event: %s", e)
+            return
+
+        event_type = envelope.get("type", "unknown")
+        event_data = envelope.get("data", {})
+
+        if event_type == "message_posted":
+            room_id = event_data.get("roomId", "")
+            event_id = event_data.get("messageEventId", "")
+            thread_root = event_data.get("threadRootEventId", "")
+            logger.info("Chatto WS: message_posted in room %s, event %s (thread=%s)", room_id, event_id, thread_root or "none")
+            if room_id and event_id and not self._is_seen(room_id, event_id):
+                await self._fetch_and_dispatch_event(room_id, event_id, thread_root)
+        elif event_type == "mention_notification":
+            room_id = event_data.get("roomId", "")
+            event_id = event_data.get("eventId", "")
+            logger.info("Chatto WS: mention notification in room %s for event %s", room_id, event_id)
+            if room_id and event_id and not self._is_seen(room_id, event_id):
+                await self._fetch_and_dispatch_event(room_id, event_id)
+        elif event_type == "new_direct_message_notification":
+            room_id = event_data.get("roomId", "")
+            event_id = event_data.get("eventId", "")
+            logger.info("Chatto WS: new DM notification in room %s for event %s", room_id, event_id)
+            if room_id and event_id and not self._is_seen(room_id, event_id):
+                await self._fetch_and_dispatch_event(room_id, event_id)
+        elif event_type == "user_joined_room":
+            room_id = event_data.get("roomId", "")
+            actor_id = envelope.get("actorId", "")
+            logger.info("Chatto WS: user_joined_room room=%s actor=%s", room_id, actor_id)
+            # If WE joined a room (or someone else joined and we should watch it),
+            # refresh room list and resubscribe
+            if room_id and room_id not in self._watch_room_ids:
+                await self._refresh_rooms()
+        elif event_type == "room_created":
+            room_id = event_data.get("roomId", "")
+            logger.info("Chatto WS: room_created room=%s", room_id)
+            # A new room was created — check if we should join/watch it
+            if room_id and room_id not in self._watch_room_ids:
+                await self._refresh_rooms()
+        elif event_type == "user_left_room":
+            room_id = event_data.get("roomId", "")
+            actor_id = envelope.get("actorId", "")
+            logger.info("Chatto WS: user_left_room room=%s actor=%s", room_id, actor_id)
+            # If WE left a room, stop watching it
+            if room_id and actor_id == self._user_id and room_id in self._watch_room_ids:
+                self._watch_room_ids.remove(room_id)
+                logger.info("Chatto WS: stopped watching room %s (we left)", room_id)
+        elif event_type == "message_edited":
+            room_id = event_data.get("roomId", "")
+            event_id = event_data.get("messageEventId", "")
+            logger.info("Chatto WS: message_edited in room %s, event %s", room_id, event_id)
+            # Log edit — could re-fetch for context if needed in the future
+        elif event_type == "message_retracted":
+            room_id = event_data.get("roomId", "")
+            event_id = event_data.get("messageEventId", "")
+            reason = event_data.get("reason", "")
+            logger.info("Chatto WS: message_retracted in room %s, event %s (reason=%s)", room_id, event_id, reason or "none")
+            # Mark the message as seen so we don't try to dispatch it later
+            if room_id and event_id:
+                self._mark_seen(room_id, event_id)
+        elif event_type == "session_terminated":
+            reason = event_data.get("reason", "")
+            logger.warning("Chatto WS: session terminated by server (reason=%s) — forcing reconnect", reason or "none")
+            # Close the websocket to trigger reconnect with backoff
+            if self._ws_ref:
+                try:
+                    await self._ws_ref.close()
+                except Exception:
+                    pass
+        else:
+            logger.debug("Chatto WS: unknown transient event type: %s", event_type)
+
+    async def _fetch_and_dispatch_event(self, room_id: str, event_id: str, thread_root_event_id: str = "") -> None:
+        """Fetch a single event by ID via REST and dispatch it.
+
+        Used as a fallback when the projection_event for a transient
+        notification (mention/DM) hasn't arrived yet.
+        When thread_root_event_id is set, fetches from the thread timeline
+        instead of the room timeline.
+        """
+        self._mark_seen(room_id, event_id)
+        if thread_root_event_id:
+            # Thread reply — use GetThreadEvents
+            status, resp = await self._rpc(_PATH_GET_THREAD_EVENTS, {
+                "roomId": room_id,
+                "threadRootEventId": thread_root_event_id,
+            })
+        else:
+            # Regular room message — use GetRoomEvents
+            status, resp = await self._rpc(_PATH_GET_ROOM_EVENTS, {"roomId": room_id})
+        if status != 200:
+            logger.warning("Chatto WS: REST fallback fetch failed for event %s (status=%d)", event_id, status)
+            return
+        events = resp.get("page", {}).get("events", [])
+        for ev in events:
+            ev_id = str(ev.get("id", ""))
+            if ev_id == event_id:
+                posted = ev.get("messagePosted")
+                if posted:
+                    msg = posted.get("message", {})
+                    if msg:
+                        # Ensure thread info is set on the message so
+                        # _dispatch_message can extract the thread root ID.
+                        if thread_root_event_id and not msg.get("thread"):
+                            msg["thread"] = {"threadRootEventId": thread_root_event_id}
+                        logger.info("Chatto WS: dispatching event %s via REST fallback (thread=%s)", event_id, thread_root_event_id or "none")
+                        await self._dispatch_message(msg, room_id)
+                    return
+        logger.warning("Chatto WS: event %s not found in room %s events (thread=%s)", event_id, room_id, thread_root_event_id or "none")
+
+    async def _refresh_rooms(self) -> None:
+        """Re-list rooms and subscribe to any new ones dynamically.
+
+        Called when a room_created or user_joined_room event arrives.
+        This avoids requiring a gateway restart to pick up new rooms.
+        """
+        try:
+            status, resp = await self._rpc(_PATH_LIST_ROOMS, {})
+            if status != 200:
+                logger.warning("Chatto WS: _refresh_rooms ListRooms failed (status=%d)", status)
+                return
+
+            rooms = resp.get("rooms", [])
+            new_room_ids = []
+            for entry in rooms:
+                room = entry.get("room", {})
+                rid = str(room.get("id", ""))
+                if not rid:
+                    continue
+                name = str(room.get("name", rid))
+                kind = str(room.get("kind", ""))
+                self._room_names[rid] = name
+                self._room_kinds[rid] = kind
+                viewer = entry.get("viewerState", {})
+                is_member = viewer.get("isMember", False)
+
+                # If we're a member and not already watching, add it
+                if is_member and rid not in self._watch_room_ids:
+                    new_room_ids.append(rid)
+
+            if not new_room_ids:
+                return
+
+            logger.info("Chatto WS: discovered %d new room(s): %s", len(new_room_ids), new_room_ids)
+
+            # Join and seed each new room
+            for rid in new_room_ids:
+                if self._room_kinds.get(rid) != "ROOM_KIND_DM":
+                    await self._join_room(rid)
+                self._seen[rid] = OrderedDict()
+                await self._seed_room(rid)
+                self._watch_room_ids.append(rid)
+
+            # Resubscribe to all rooms (including new ones) via the open websocket
+            if self._ws_ref and self._ws_active:
+                subscribe_body = _encode_subscribe_events(
+                    resume_cursor=self._resume_cursor,
+                    retained_room_ids=self._watch_room_ids,
+                )
+                subscribe_frame = _encode_client_frame_subscribe(subscribe_body)
+                await self._ws_ref.send(subscribe_frame)
+                logger.info("Chatto WS: resubscribed with %d room(s)", len(self._watch_room_ids))
+            else:
+                logger.warning("Chatto WS: cannot resubscribe — websocket not active")
+
+        except Exception:
+            logger.warning("Chatto WS: _refresh_rooms failed", exc_info=True)
+
+    async def _dispatch_message(self, msg: dict, room_id: str) -> None:
+        """Build a MessageEvent and hand it to the base class handler.
+
+        This method is identical to the polling version — it receives a
+        message dict (decoded from protobuf) and dispatches it through the
+        standard Hermes message pipeline.
+        """
+        if not self._message_handler:
+            return
+
+        actor_id = str(msg.get("actorId", ""))
+        # Skip our own messages
+        if actor_id == self._user_id:
+            return
+
+        # Best-effort: cache the sender's display name for richer message context
+        if actor_id and actor_id not in self._user_cache:
+            try:
+                await self.get_user(actor_id)
+            except Exception:
+                logger.debug("Chatto: get_user(%s) failed during dispatch", actor_id, exc_info=True)
+
+        body = str(msg.get("body", ""))
+        if not body:
+            return
+
+        msg_id = str(msg.get("id", ""))
+        chat_type = "dm" if self._room_kinds.get(room_id) == "ROOM_KIND_DM" else "group"
+
+        # Mention detection
+        is_dm = chat_type == "dm"
+        mentioned = False
+        if self._user_login:
+            mentioned = f"@{self._user_login}" in body
+        if self._user_display:
+            mentioned = mentioned or f"@{self._user_display}" in body
+
+        if self._require_mention and not is_dm and not mentioned:
+            # Allow free-response rooms (like Discord's free_response_channels)
+            if room_id not in self._free_response_channels:
+                return
+
+        # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
+        # Strip the mention from the text for the agent
+        text = body
+        if mentioned and not is_dm:
+            # Remove mention prefix if present
+            if self._user_login and text.startswith(f"@{self._user_login}"):
+                text = text[len(f"@{self._user_login}"):].lstrip()
+            elif self._user_display and text.startswith(f"@{self._user_display}"):
+                text = text[len(f"@{self._user_display}"):].lstrip()
+
+        # Resolve user display name from actorLogin or actorDisplayName
+        user_name = str(msg.get("actorLogin", "")) or str(msg.get("actorDisplayName", actor_id))
+
+        thread_id = None
+        thread_info = msg.get("thread", {})
+        if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
+            thread_id = str(thread_info.get("threadRootEventId", ""))
+
+        source = self.build_source(
+            chat_id=room_id,
+            chat_name=self._room_names.get(room_id, room_id),
+            chat_type=chat_type,
+            user_id=actor_id,
+            user_name=user_name,
+            thread_id=thread_id,
+        )
+
+        created_at_str = str(msg.get("createdAt", ""))
+        try:
+            timestamp = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) if created_at_str else datetime.now()
+        except (ValueError, TypeError):
+            timestamp = datetime.now()
+
+        event = MessageEvent(
+            text=text,
+            message_type=MessageType.TEXT,
+            source=source,
+            message_id=msg_id,
+            timestamp=timestamp,
+            raw_message=msg,
+        )
+
+        await self.handle_message(event)
+
+        # ------------------------------------------------------------------ #
+        # Read state & notification dismissal (best-effort, Chatto-unique)
+        # ------------------------------------------------------------------ #
+        try:
+            await self.mark_room_as_read(room_id)
+        except Exception:
+            logger.debug("Chatto: mark_room_as_read failed for %s", room_id, exc_info=True)
+        try:
+            await self.dismiss_all_notifications()
+        except Exception:
+            logger.debug("Chatto: dismiss_all_notifications failed", exc_info=True)
+
+    # ------------------------------------------------------------------ #
+    # Sending (REST — unchanged from polling version)
+    # ------------------------------------------------------------------ #
+
+    async def send(
+        self,
+        chat_id: str,
+        content: str,
+        reply_to: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> SendResult:
+        """Send a message to a Chatto room.
+
+        Long messages are split into chunks via ``truncate_message`` and
+        each chunk is sent as a separate CreateMessage call.  The first
+        chunk's message ID is returned as ``message_id``.
+
+        When ``auto_thread`` is enabled and the incoming message was a
+        regular room message (not already in a thread), the first chunk is
+        sent as a room message and its ID becomes the thread root. Subsequent
+        chunks are sent in that thread. This mirrors Discord's auto_thread
+        behavior.
+        """
+        if not content:
+            return SendResult(success=False, error="Empty message")
+
+        formatted = self.format_message(content) if hasattr(self, "format_message") else content
+        chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+
+        # Thread support — resolve thread_id once
+        # DM rooms don't support threads, so skip threading for DMs
+        thread_id = (metadata or {}).get("thread_id")
+        # Only use reply_to as thread_id if auto_thread is enabled.
+        # When auto_thread=false, responses go directly in the room
+        # without threading under the incoming message.
+        auto_thread_env = os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()
+        auto_thread_setting = auto_thread_env in ("true", "1", "yes") if auto_thread_env else True
+        if reply_to and auto_thread_setting:
+            # reply_to might be the incoming message ID. If we already have
+            # thread_id from metadata, keep it (it's the thread root).
+            # Only use reply_to as thread_id if we don't already have one.
+            if not thread_id:
+                thread_id = reply_to
+        # Check if this is a DM room — DMs don't support threads
+        room_kind = self._room_kinds.get(str(chat_id), "")
+        is_dm = room_kind == "ROOM_KIND_DM" or room_kind == "dm"
+        if is_dm:
+            thread_id = None
+
+        # Auto-thread: by default, Chatto creates a thread for replies to room
+        # messages (not DMs, not already in a thread). This keeps conversations
+        # organized in the room. Can be disabled via extra.auto_thread=false.
+        auto_thread_enabled = os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()
+        if auto_thread_enabled:
+            auto_thread_enabled = auto_thread_enabled in ("true", "1", "yes")
+        else:
+            auto_thread_enabled = True  # default: enabled
+        use_auto_thread = auto_thread_enabled and not thread_id and not is_dm
+
+        message_ids: List[str] = []
+        last_resp: Optional[dict] = None
+        last_error: Optional[str] = None
+        retryable = False
+
+        for i, chunk in enumerate(chunks):
+            body: Dict[str, Any] = {"roomId": str(chat_id), "body": chunk}
+            # If we have a thread_id, send in the thread
+            if thread_id:
+                body["threadRootEventId"] = str(thread_id)
+
+            status, resp = await self._rpc(_PATH_CREATE_MESSAGE, body)
+            if status != 200:
+                err = resp.get("message") or resp.get("error") or f"HTTP {status}"
+                last_error = err
+                retryable = status >= 500 or status == 401
+                break
+
+            last_resp = resp
+            msg = resp.get("message", {})
+            msg_id = str(msg.get("id", "")) if msg else ""
+            if msg_id:
+                self._mark_seen(str(chat_id), msg_id)
+                message_ids.append(msg_id)
+                self._our_message_ids.add(msg_id)
+                # If we sent a message WITHOUT a thread_id, this message could
+                # become a thread root if someone replies to it
+                if not thread_id:
+                    self._our_thread_roots.add(msg_id)
+                # Auto-thread: first chunk becomes the thread root,
+                # subsequent chunks go in the thread
+                if use_auto_thread and i == 0 and not thread_id:
+                    thread_id = msg_id
+
+        if last_error and not message_ids:
+            return SendResult(success=False, error=last_error, retryable=retryable)
+
+        first_id = message_ids[0] if message_ids else ""
+
+        # ------------------------------------------------------------------ #
+        # Thread following (best-effort, Chatto-unique)
+        # ------------------------------------------------------------------ #
+        if thread_id and message_ids:
+            try:
+                await self._follow_thread(str(chat_id), str(thread_id))
+            except Exception:
+                logger.debug("Chatto: _follow_thread failed for room=%s thread=%s",
+                             chat_id, thread_id, exc_info=True)
+
+        return SendResult(success=True, message_id=first_id, raw_response=last_resp)
+
+    async def send_typing(self, chat_id: str, metadata=None) -> None:
+        """Start a persistent typing indicator for a room.
+
+        Sends a typing ping every 10 seconds (Chatto's indicator likely
+        lasts ~8-10s).  The background loop runs until ``stop_typing()``
+        is called or the task is cancelled.
+        """
+        if chat_id in self._typing_tasks:
+            return  # already running
+
+        async def _typing_loop() -> None:
+            try:
+                while True:
+                    try:
+                        await self._rpc(
+                            _PATH_UPDATE_TYPING,
+                            {"roomId": str(chat_id), "typing": True},
+                            retry=False,
+                        )
+                    except asyncio.CancelledError:
+                        return
+                    except Exception:
+                        pass
+                    await asyncio.sleep(10)
+            except asyncio.CancelledError:
+                pass
+            finally:
+                self._typing_tasks.pop(chat_id, None)
+
+        self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
+
+    async def stop_typing(self, chat_id: str) -> None:
+        """Stop the persistent typing indicator for a room."""
+        task = self._typing_tasks.pop(chat_id, None)
+        if task:
+            task.cancel()
+            try:
+                await task
+            except (asyncio.CancelledError, Exception):
+                pass
+
+    async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+        """Get information about a chat/room."""
+        name = self._room_names.get(chat_id, chat_id)
+        kind = self._room_kinds.get(chat_id, "")
+        chat_type = "dm" if kind == "ROOM_KIND_DM" else "group"
+        return {
+            "name": name,
+            "type": chat_type,
+        }
+
+    # ------------------------------------------------------------------ #
+    # Reactions
+    # ------------------------------------------------------------------ #
+
+    @staticmethod
+    def _emoji_to_shortcode(emoji: str) -> str:
+        """Convert a unicode emoji to a Chatto shortcode name.
+
+        If the emoji is already a shortcode (no unicode mapping found),
+        return it as-is.
+        """
+        shortcode = _EMOJI_TO_SHORTCODE.get(emoji)
+        if shortcode:
+            return shortcode
+        # Already a shortcode like "thumbsup" — return as-is
+        return emoji
+
+    async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
+        """Add a reaction to a message via MessageService/AddReaction."""
+        shortcode = self._emoji_to_shortcode(emoji)
+        body = {
+            "roomId": str(chat_id),
+            "messageEventId": str(message_id),
+            "emoji": shortcode,
+        }
+        try:
+            status, resp = await self._rpc(_PATH_ADD_REACTION, body, retry=False)
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: AddReaction failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: AddReaction error: %s", e)
+            return False
+
+    async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
+        """Remove a reaction from a message via MessageService/RemoveReaction."""
+        shortcode = self._emoji_to_shortcode(emoji)
+        body = {
+            "roomId": str(chat_id),
+            "messageEventId": str(message_id),
+            "emoji": shortcode,
+        }
+        try:
+            status, resp = await self._rpc(_PATH_REMOVE_REACTION, body, retry=False)
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: RemoveReaction failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: RemoveReaction error: %s", e)
+            return False
+
+    # ------------------------------------------------------------------ #
+    # Read state management (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def mark_room_as_read(self, room_id: str) -> bool:
+        """Mark a room as read via RoomService/MarkRoomAsRead."""
+        try:
+            status, resp = await self._rpc(
+                _PATH_MARK_ROOM_READ, {"roomId": str(room_id)}, retry=False
+            )
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: MarkRoomAsRead failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: MarkRoomAsRead error: %s", e)
+            return False
+
+    async def mark_thread_as_read(self, room_id: str, thread_root_event_id: str) -> bool:
+        """Mark a thread as read via ThreadService/MarkThreadAsRead."""
+        try:
+            status, resp = await self._rpc(
+                _PATH_MARK_THREAD_READ,
+                {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
+                retry=False,
+            )
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: MarkThreadAsRead failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: MarkThreadAsRead error: %s", e)
+            return False
+
+    # ------------------------------------------------------------------ #
+    # DM initiation (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def start_dm(self, user_id: str) -> Optional[str]:
+        """Start a direct message with a user via RoomService/StartDM.
+
+        Returns the room ID on success, or None on failure.
+        """
+        body: Dict[str, Any] = {"participantIds": [str(user_id)] if user_id else []}
+        try:
+            status, resp = await self._rpc(_PATH_START_DM, body, retry=True)
+            if status != 200:
+                logger.debug(
+                    "Chatto: StartDM failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return None
+            room = resp.get("room", {})
+            rid = str(room.get("id", "")) if room else ""
+            if rid:
+                self._room_names[rid] = self._room_names.get(rid, "")
+                self._room_kinds[rid] = "ROOM_KIND_DM"
+                return rid
+            logger.debug("Chatto: StartDM returned no room id: %s", resp)
+            return None
+        except Exception as e:
+            logger.debug("Chatto: StartDM error: %s", e)
+            return None
+
+    # ------------------------------------------------------------------ #
+    # Thread following (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def _follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
+        """Best-effort: follow a thread via ThreadService/FollowThread."""
+        try:
+            status, resp = await self._rpc(
+                _PATH_FOLLOW_THREAD,
+                {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
+                retry=False,
+            )
+            if status != 200:
+                logger.debug(
+                    "Chatto: FollowThread failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+        except Exception as e:
+            logger.debug("Chatto: FollowThread error: %s", e)
+
+    # ------------------------------------------------------------------ #
+    # Room creation (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def create_room(
+        self,
+        name: str,
+        description: str = "",
+        group_id: str = "",
+        universal: bool = True,
+    ) -> Optional[str]:
+        """Create an ad-hoc room via RoomService/CreateRoom.
+
+        Returns the room ID on success, or None on failure.
+        """
+        body: Dict[str, Any] = {
+            "name": name,
+            "description": description,
+            "groupId": group_id,
+            "universal": universal,
+        }
+        try:
+            status, resp = await self._rpc(_PATH_CREATE_ROOM, body, retry=True)
+            if status != 200:
+                logger.debug(
+                    "Chatto: CreateRoom failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return None
+            room = resp.get("room", {})
+            rid = str(room.get("id", "")) if room else ""
+            if rid:
+                self._room_names[rid] = name
+                self._room_kinds[rid] = "ROOM_KIND_GROUP"
+                return rid
+            logger.debug("Chatto: CreateRoom returned no room id: %s", resp)
+            return None
+        except Exception as e:
+            logger.debug("Chatto: CreateRoom error: %s", e)
+            return None
+
+    # ------------------------------------------------------------------ #
+    # Notification dismissal (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def dismiss_all_notifications(self) -> bool:
+        """Dismiss all notifications via NotificationService/DismissAllNotifications."""
+        try:
+            status, resp = await self._rpc(
+                _PATH_DISMISS_ALL_NOTIFICATIONS, {}, retry=False
+            )
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: DismissAllNotifications failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: DismissAllNotifications error: %s", e)
+            return False
+
+    async def dismiss_notification(self, notification_id: str) -> bool:
+        """Dismiss a single notification via NotificationService/DismissNotification."""
+        try:
+            status, resp = await self._rpc(
+                _PATH_DISMISS_NOTIFICATION,
+                {"notificationId": str(notification_id)},
+                retry=False,
+            )
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: DismissNotification failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: DismissNotification error: %s", e)
+            return False
+
+    # ------------------------------------------------------------------ #
+    # Message editing and deletion
+    # ------------------------------------------------------------------ #
+
+    async def edit_message(
+        self,
+        chat_id: str,
+        message_id: str,
+        new_content: str,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> bool:
+        """Edit a previously sent message via MessageService/UpdateMessage."""
+        body = {
+            "roomId": str(chat_id),
+            "eventId": str(message_id),
+            "body": new_content,
+        }
+        try:
+            status, resp = await self._rpc(_PATH_UPDATE_MESSAGE, body, retry=True)
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: UpdateMessage failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: UpdateMessage error: %s", e)
+            return False
+
+    async def delete_message(
+        self,
+        chat_id: str,
+        message_id: str,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> bool:
+        """Delete a previously sent message via MessageService/DeleteMessage."""
+        body = {
+            "roomId": str(chat_id),
+            "eventId": str(message_id),
+        }
+        try:
+            status, resp = await self._rpc(_PATH_DELETE_MESSAGE, body, retry=True)
+            if status == 200:
+                return True
+            logger.debug(
+                "Chatto: DeleteMessage failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: DeleteMessage error: %s", e)
+            return False
+
+    # ------------------------------------------------------------------ #
+    # Processing lifecycle hooks (reactions-based, like Discord)
+    # ------------------------------------------------------------------ #
+
+    def _reactions_enabled(self) -> bool:
+        """Check if processing reactions are enabled."""
+        return os.getenv("CHATTO_REACTIONS", "true").lower() not in {"false", "0", "no"}
+
+    def _event_room_and_message_id(self, event: MessageEvent) -> Tuple[str, str]:
+        """Extract room_id and message_id from a MessageEvent."""
+        chat_id = ""
+        message_id = str(event.message_id or "")
+        source = event.source
+        if source:
+            chat_id = str(getattr(source, "chat_id", "") or "")
+        # Fallback: try raw_message dict
+        if not chat_id or not message_id:
+            raw = event.raw_message
+            if isinstance(raw, dict):
+                if not chat_id:
+                    chat_id = str(raw.get("roomId", "") or "")
+                if not message_id:
+                    message_id = str(raw.get("id", "") or "")
+        return chat_id, message_id
+
+    async def on_processing_start(self, event: MessageEvent) -> None:
+        """Add an 👀 (eyes) reaction to the incoming message."""
+        if not self._reactions_enabled():
+            return
+        chat_id, message_id = self._event_room_and_message_id(event)
+        if not chat_id or not message_id:
+            return
+        await self.send_reaction(chat_id, message_id, "👀")
+
+    async def on_processing_complete(
+        self, event: MessageEvent, outcome: ProcessingOutcome
+    ) -> None:
+        """Swap the 👀 reaction for ✅ (success) or ❌ (failure)."""
+        if not self._reactions_enabled():
+            return
+        chat_id, message_id = self._event_room_and_message_id(event)
+        if not chat_id or not message_id:
+            return
+        # Remove the processing eyes reaction
+        await self.remove_reaction(chat_id, message_id, "👀")
+        # Add the outcome reaction
+        if outcome == ProcessingOutcome.SUCCESS:
+            await self.send_reaction(chat_id, message_id, "✅")
+        elif outcome == ProcessingOutcome.FAILURE:
+            await self.send_reaction(chat_id, message_id, "❌")
+
+    # ------------------------------------------------------------------ #
+    # Asset upload (chunked)
+    # ------------------------------------------------------------------ #
+
+    async def _upload_asset(self, room_id: str, file_path: str) -> Optional[str]:
+        """Upload a file via the chunked AssetUploadService.
+
+        Returns the asset ID on success, or None on failure.
+        """
+        try:
+            with open(file_path, "rb") as f:
+                file_data = f.read()
+        except Exception as e:
+            logger.error("Chatto: failed to read file %s — %s", file_path, e)
+            return None
+
+        if not file_data:
+            logger.error("Chatto: file %s is empty", file_path)
+            return None
+
+        file_size = len(file_data)
+        file_name = os.path.basename(file_path)
+        mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+        sha256_hash = hashlib.sha256(file_data).hexdigest()
+
+        # Step 1: Create upload session
+        create_body = {
+            "roomId": room_id,
+            "filename": file_name,
+            "contentType": mime_type,
+            "size": file_size,
+            "sha256": sha256_hash,
+        }
+        status, resp = await self._rpc(_PATH_CREATE_UPLOAD, create_body)
+        if status != 200:
+            logger.error(
+                "Chatto: CreateUpload failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return None
+
+        upload_id = str(resp.get("upload", {}).get("id", ""))
+        if not upload_id:
+            logger.error("Chatto: CreateUpload returned no upload ID: %s", resp)
+            return None
+
+        # Step 2: Upload chunks
+        offset = 0
+        while offset < file_size:
+            chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
+            chunk_b64 = base64.b64encode(chunk).decode("ascii")
+            chunk_sha256 = hashlib.sha256(chunk).hexdigest()
+            chunk_body = {
+                "uploadId": upload_id,
+                "offset": offset,
+                "content": chunk_b64,
+                "chunkSha256": chunk_sha256,
+            }
+            status, resp = await self._rpc(_PATH_UPLOAD_CHUNK, chunk_body)
+            if status != 200:
+                logger.error(
+                    "Chatto: UploadChunk failed at offset %d (%s): %s",
+                    offset,
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return None
+            offset += len(chunk)
+
+        # Step 3: Complete upload
+        complete_body = {"uploadId": upload_id}
+        status, resp = await self._rpc(_PATH_COMPLETE_UPLOAD, complete_body)
+        if status != 200:
+            logger.error(
+                "Chatto: CompleteUpload failed (%s): %s",
+                status,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return None
+
+        asset_id = str(resp.get("asset", {}).get("id", ""))
+        if not asset_id:
+            logger.error("Chatto: CompleteUpload returned no asset ID: %s", resp)
+            return None
+
+        logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
+        return asset_id
+
+    async def send_image_file(
+        self,
+        chat_id: str,
+        file_path: str,
+        caption: Optional[str] = None,
+        reply_to: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> SendResult:
+        """Send a local image file via the chunked upload API."""
+        # Validate the path is safe
+        safe_path = self.validate_media_delivery_path(file_path)
+        if not safe_path:
+            logger.warning("Chatto: send_image_file — unsafe path %s", file_path)
+            text = "⚠️ Couldn't deliver the image attachment."
+            if caption:
+                text = f"{caption}\n{text}"
+            return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
+
+        asset_id = await self._upload_asset(str(chat_id), safe_path)
+        if not asset_id:
+            # Fallback to a notice
+            text = "⚠️ Couldn't deliver the image attachment."
+            if caption:
+                text = f"{caption}\n{text}"
+            return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
+
+        body: Dict[str, Any] = {
+            "roomId": str(chat_id),
+            "body": caption or "",
+            "attachmentAssetIds": [asset_id],
+        }
+
+        thread_id = (metadata or {}).get("thread_id")
+        if reply_to:
+            thread_id = reply_to
+        if thread_id:
+            body["threadRootEventId"] = str(thread_id)
+
+        status, resp = await self._rpc(_PATH_CREATE_MESSAGE, body)
+        if status != 200:
+            err = resp.get("message") or resp.get("error") or f"HTTP {status}"
+            return SendResult(success=False, error=err, retryable=status >= 500 or status == 401)
+
+        msg = resp.get("message", {})
+        msg_id = str(msg.get("id", "")) if msg else ""
+        if msg_id:
+            self._mark_seen(str(chat_id), msg_id)
+        return SendResult(success=True, message_id=msg_id, raw_response=resp)
+
+    async def send_image(
+        self,
+        chat_id: str,
+        image_url: str,
+        caption: Optional[str] = None,
+        reply_to: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> SendResult:
+        """Send an image to a Chatto room.
+
+        Tries to download the image from the URL and upload it as a native
+        attachment.  Falls back to sending the URL as a link (Chatto renders
+        link previews) if the download fails.
+        """
+        # Try downloading and uploading as attachment
+        try:
+            import tempfile
+            import urllib.request as _urllib_request
+
+            # Download to a temp file
+            parsed = urlsplit(image_url)
+            url_path = parsed.path
+            ext = os.path.splitext(url_path)[1] or ".png"
+            tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
+            try:
+                os.close(tmp_fd)
+                req = _urllib_request.Request(image_url, headers={"User-Agent": "Hermes/1.0"})
+                ctx = _ssl_context()
+                with _urllib_request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
+                    with open(tmp_path, "wb") as f:
+                        f.write(resp.read())
+
+                # Upload as attachment
+                result = await self.send_image_file(
+                    chat_id, tmp_path, caption=caption,
+                    reply_to=reply_to, metadata=metadata,
+                )
+                if result.success:
+                    return result
+            finally:
+                try:
+                    os.unlink(tmp_path)
+                except OSError:
+                    pass
+        except Exception as e:
+            logger.debug("Chatto: send_image download/upload failed, falling back to link: %s", e)
+
+        # Fallback: send as link (Chatto renders link previews)
+        text = image_url
+        if caption:
+            text = f"{caption}\n{image_url}"
+        return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
+
+    # ------------------------------------------------------------------ #
+    # Platform properties
+    # ------------------------------------------------------------------ #
+
+    @property
+    def platform_name(self) -> str:
+        return "chatto"
+
+    @property
+    def supports_markdown(self) -> bool:
+        return True
+
+    @property
+    def supports_reactions(self) -> bool:
+        return True
+
+    # ------------------------------------------------------------------ #
+    # Member directory — user lookup and mention resolution (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def list_users(self) -> list:
+        """List all server members via UserService/ListUsers.
+
+        Returns a list of user dicts. Each dict typically contains
+        ``id``, ``login``, and ``displayName`` keys.
+        """
+        try:
+            status, resp = await self._rpc(_PATH_LIST_USERS, {}, retry=True)
+            if status != 200:
+                logger.debug(
+                    "Chatto: ListUsers failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return []
+            users = resp.get("users", [])
+            # Cache all returned users
+            for u in users:
+                uid = str(u.get("id", ""))
+                if uid:
+                    self._user_cache[uid] = u
+            return users
+        except Exception as e:
+            logger.debug("Chatto: ListUsers error: %s", e)
+            return []
+
+    async def get_user(self, user_id: str) -> Optional[dict]:
+        """Get a single user by ID via UserService/GetUser.
+
+        Returns the user dict (containing ``id``, ``login``,
+        ``displayName``) or ``None`` on failure. Results are cached in
+        ``self._user_cache``.
+        """
+        if not user_id:
+            return None
+        # Return cached entry if available
+        if user_id in self._user_cache:
+            return self._user_cache[user_id]
+        try:
+            status, resp = await self._rpc(
+                _PATH_GET_USER, {"userId": str(user_id)}, retry=True
+            )
+            if status != 200:
+                logger.debug(
+                    "Chatto: GetUser failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return None
+            user = resp.get("user")
+            if user:
+                uid = str(user.get("id", ""))
+                if uid:
+                    self._user_cache[uid] = user
+                return user
+            return None
+        except Exception as e:
+            logger.debug("Chatto: GetUser error: %s", e)
+            return None
+
+    async def batch_get_users(self, user_ids: list) -> list:
+        """Batch-fetch multiple users via UserService/BatchGetUsers.
+
+        Returns a list of user dicts. Cached entries are reused and only
+        uncached IDs are fetched from the server.
+        """
+        if not user_ids:
+            return []
+        # Separate cached from uncached
+        cached: list = []
+        uncached_ids: list = []
+        for uid in user_ids:
+            uid_str = str(uid)
+            if uid_str in self._user_cache:
+                cached.append(self._user_cache[uid_str])
+            else:
+                uncached_ids.append(uid_str)
+        if not uncached_ids:
+            return cached
+        try:
+            status, resp = await self._rpc(
+                _PATH_BATCH_GET_USERS, {"userIds": uncached_ids}, retry=True
+            )
+            if status != 200:
+                logger.debug(
+                    "Chatto: BatchGetUsers failed (%s): %s",
+                    status,
+                    resp.get("message") or resp.get("error") or "",
+                )
+                return cached
+            fetched = resp.get("users", [])
+            for u in fetched:
+                uid = str(u.get("id", ""))
+                if uid:
+                    self._user_cache[uid] = u
+            return cached + fetched
+        except Exception as e:
+            logger.debug("Chatto: BatchGetUsers error: %s", e)
+            return cached
+
+    # ------------------------------------------------------------------ #
+    # Presence broadcasting (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def set_presence(self, status: str) -> bool:
+        """Update the bot's presence status via MyAccountService/UpdatePresence.
+
+        Accepts string values ``"online"``, ``"away"``, ``"dnd"`` (or
+        ``"do_not_disturb"``) and maps them to Chatto's integer status
+        codes: 1=ONLINE, 2=AWAY, 3=DO_NOT_DISTURB.
+        Returns ``True`` on success.
+        """
+        status_lower = status.lower().strip()
+        status_int = _PRESENCE_STATUS_MAP.get(status_lower)
+        if status_int is None:
+            logger.warning("Chatto: unknown presence status %r", status)
+            return False
+        try:
+            status_code, resp = await self._rpc(
+                _PATH_UPDATE_PRESENCE, {"status": status_int}, retry=False
+            )
+            if status_code == 200:
+                logger.debug("Chatto: presence set to %s (%d)", status_lower, status_int)
+                return True
+            logger.debug(
+                "Chatto: UpdatePresence failed (%s): %s",
+                status_code,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: UpdatePresence error: %s", e)
+            return False
+
+    # ------------------------------------------------------------------ #
+    # Custom status messages (Chatto-unique)
+    # ------------------------------------------------------------------ #
+
+    async def set_custom_status(self, text: str) -> bool:
+        """Set a custom status message via MyAccountService/UpdateCustomStatus.
+
+        The status text is a plain string (max ~100 chars). Useful for
+        indicating long-running operations, e.g. ``"Processing..."``.
+        Returns ``True`` on success.
+        """
+        if not text:
+            return False
+        # Truncate to a reasonable length
+        status_text = text.strip()[:100]
+        if not status_text:
+            return False
+        try:
+            status_code, resp = await self._rpc(
+                _PATH_UPDATE_CUSTOM_STATUS, {"status": status_text}, retry=False
+            )
+            if status_code == 200:
+                logger.debug("Chatto: custom status set to %r", status_text)
+                return True
+            logger.debug(
+                "Chatto: UpdateCustomStatus failed (%s): %s",
+                status_code,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: UpdateCustomStatus error: %s", e)
+            return False
+
+    async def clear_custom_status(self) -> bool:
+        """Clear the custom status message via MyAccountService/DeleteCustomStatus.
+
+        Returns ``True`` on success.
+        """
+        try:
+            status_code, resp = await self._rpc(
+                _PATH_DELETE_CUSTOM_STATUS, {}, retry=False
+            )
+            if status_code == 200:
+                logger.debug("Chatto: custom status cleared")
+                return True
+            logger.debug(
+                "Chatto: DeleteCustomStatus failed (%s): %s",
+                status_code,
+                resp.get("message") or resp.get("error") or "",
+            )
+            return False
+        except Exception as e:
+            logger.debug("Chatto: DeleteCustomStatus error: %s", e)
+            return False
+
+    @property
+    def supports_threads(self) -> bool:
+        return True
+
+
+# --------------------------------------------------------------------------- #
+# Plugin registration
+# --------------------------------------------------------------------------- #
+
+def check_requirements() -> bool:
+    """Check if Chatto is configured."""
+    return bool(
+        os.getenv("CHATTO_URL", "").strip()
+        and os.getenv("CHATTO_LOGIN", "").strip()
+        and os.getenv("CHATTO_PASSWORD", "").strip()
+    )
+
+
+def validate_config(config) -> bool:
+    """Validate that the platform config has enough info to connect."""
+    extra = getattr(config, "extra", {}) or {}
+    url = os.getenv("CHATTO_URL") or str(extra.get("url", ""))
+    login = os.getenv("CHATTO_LOGIN", "").strip()
+    password = os.getenv("CHATTO_PASSWORD", "").strip()
+    return bool(url and login and password)
+
+
+def is_connected(config) -> bool:
+    """Check whether Chatto is configured."""
+    return validate_config(config)
+
+
+def _apply_yaml_config(yaml_cfg: dict, chatto_cfg: dict) -> Optional[dict]:
+    """Translate config.yaml chatto.extra keys into CHATTO_* env vars."""
+    extra = chatto_cfg.get("extra") if isinstance(chatto_cfg.get("extra"), dict) else {}
+    mapping = {
+        "url": "CHATTO_URL",
+        "home_channel": "CHATTO_HOME_CHANNEL",
+        "require_mention": "CHATTO_REQUIRE_MENTION",
+        "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
+        "auto_thread": "CHATTO_AUTO_THREAD",
+    }
+    for yaml_key, env_key in mapping.items():
+        val = extra.get(yaml_key)
+        if val is not None and not os.getenv(env_key):
+            if isinstance(val, bool):
+                os.environ[env_key] = str(val).lower()
+            elif isinstance(val, list):
+                os.environ[env_key] = ",".join(str(v) for v in val)
+            else:
+                os.environ[env_key] = str(val)
+    channels = extra.get("channels")
+    if isinstance(channels, list) and not os.getenv("CHATTO_CHANNELS"):
+        os.environ["CHATTO_CHANNELS"] = ",".join(str(c) for c in channels)
+    allowed = extra.get("allowed_users")
+    if isinstance(allowed, list) and not os.getenv("CHATTO_ALLOWED_USERS"):
+        os.environ["CHATTO_ALLOWED_USERS"] = ",".join(str(u) for u in allowed)
+    if "allow_all_users" in extra and not os.getenv("CHATTO_ALLOW_ALL_USERS"):
+        os.environ["CHATTO_ALLOW_ALL_USERS"] = str(extra["allow_all_users"]).lower()
+    # Return nothing to merge — all config flows through env
+    return None
+
+
+def _env_enablement() -> Optional[dict]:
+    """Seed PlatformConfig.extra from env vars for env-only setups."""
+    url = os.getenv("CHATTO_URL", "").strip()
+    if not url:
+        return None
+    extra = {"url": url}
+    home = os.getenv("CHATTO_HOME_CHANNEL", "").strip()
+    if home:
+        extra["home_channel"] = home
+    channels = os.getenv("CHATTO_CHANNELS", "").strip()
+    if channels:
+        extra["channels"] = [c.strip() for c in channels.split(",") if c.strip()]
+    rm = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
+    if rm:
+        extra["require_mention"] = rm in ("true", "1", "yes")
+    home_dict = {"home_channel": home} if home else None
+    return {"extra": extra, "home_channel": home_dict}
+
+
+async def _standalone_send(
+    base_url: str,
+    login: str,
+    password: str,
+    room_id: str,
+    content: str,
+    thread_id: Optional[str] = None,
+) -> dict:
+    """Out-of-process send for cron delivery (no live adapter needed)."""
+    token = _auth_login(base_url, login, password)
+    if not token:
+        return {"success": False, "error": "login failed"}
+    body: Dict[str, Any] = {"roomId": room_id, "body": content}
+    if thread_id:
+        body["threadRootEventId"] = thread_id
+    status, resp = _rpc_request(base_url, _PATH_CREATE_MESSAGE, token, body)
+    if status == 200:
+        return {"success": True, "response": resp}
+    return {"success": False, "error": resp.get("message", f"HTTP {status}"), "status": status}
+
+
+def interactive_setup() -> None:
+    """Interactive setup wizard for Chatto."""
+    from hermes_cli.gateway import prompt_env, set_env_var
+
+    url = prompt_env("Chatto server URL (e.g. https://chat.example.com):")
+    if url:
+        set_env_var("CHATTO_URL", url)
+    login = prompt_env("Chatto login (username):")
+    if login:
+        set_env_var("CHATTO_LOGIN", login)
+    password = prompt_env("Chatto password:", password=True)
+    if password:
+        set_env_var("CHATTO_PASSWORD", password)
+    channels = prompt_env("Room IDs to watch (comma-separated, or empty for all):")
+    if channels:
+        set_env_var("CHATTO_CHANNELS", channels)
+    home = prompt_env("Home room ID for notifications (or empty):")
+    if home:
+        set_env_var("CHATTO_HOME_CHANNEL", home)
+    allow_all = prompt_env("Allow all users? (true/false):")
+    if allow_all:
+        set_env_var("CHATTO_ALLOW_ALL_USERS", allow_all)
+    print("\n✓ Chatto configured. Restart the gateway to activate.")
+
+
+def register(ctx) -> None:
+    """Plugin entry point — called by the Hermes plugin system."""
+    ctx.register_platform(
+        name="chatto",
+        label="Chatto",
+        adapter_factory=lambda cfg: ChattoAdapter(cfg),
+        check_fn=check_requirements,
+        validate_config=validate_config,
+        is_connected=is_connected,
+        required_env=["CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
+        install_hint="Requires a Chatto server. See https://docs.chatto.run",
+        setup_fn=interactive_setup,
+        apply_yaml_config_fn=_apply_yaml_config,
+        cron_deliver_env_var="CHATTO_HOME_CHANNEL",
+        standalone_sender_fn=_standalone_send,
+        allowed_users_env="CHATTO_ALLOWED_USERS",
+        allow_all_env="CHATTO_ALLOW_ALL_USERS",
+        max_message_length=_MAX_MESSAGE_LENGTH,
+        emoji="💬",
+        allow_update_command=True,
+        pii_safe=False,
+        platform_hint=(
+            "You are chatting in Chatto (a self-hosted team chat server). "
+            "Markdown IS supported. Users address you by @-mentioning your name "
+            "in rooms; direct messages reach you without a mention. "
+            "Keep responses conversational."
+        ),
+    )

+ 54 - 0
plugins/platforms/chatto/plugin.yaml

@@ -0,0 +1,54 @@
+name: chatto-platform
+label: Chatto
+kind: platform
+version: 1.0.0
+description: >
+  Chatto gateway adapter for Hermes Agent.
+  Connects to a Chatto server (self-hosted team chat) and relays messages
+  between rooms/DMs and the Hermes agent.  Uses the Chatto ConnectRPC API
+  (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
+  protocol (binary protobuf) for inbound events.  No Python packages
+  required beyond websockets — pure stdlib protobuf codec.
+author: Nous Research
+requires_env:
+  - name: CHATTO_URL
+    description: "Base URL of the Chatto server (e.g. https://chat.example.com)"
+    prompt: "Chatto server URL"
+    password: false
+  - name: CHATTO_LOGIN
+    description: "Chatto login (username)"
+    prompt: "Chatto login"
+    password: false
+  - name: CHATTO_PASSWORD
+    description: "Chatto password"
+    prompt: "Chatto password"
+    password: true
+optional_env:
+  - name: CHATTO_CHANNELS
+    description: "Comma-separated room IDs to watch (default: all joined rooms)"
+    prompt: "Room IDs (comma-separated)"
+    password: false
+  - name: CHATTO_HOME_CHANNEL
+    description: "Room ID for cron / notification delivery (defaults to the first watched room)"
+    prompt: "Home room ID (or empty)"
+    password: false
+  - name: CHATTO_ALLOWED_USERS
+    description: "Comma-separated Chatto logins allowed to talk to the agent"
+    prompt: "Allowed users (comma-separated)"
+    password: false
+  - name: CHATTO_ALLOW_ALL_USERS
+    description: "Allow any Chatto user to talk to the agent (true/false)"
+    prompt: "Allow all users? (true/false)"
+    password: false
+  - name: CHATTO_REQUIRE_MENTION
+    description: "Only respond to @mentions in rooms (default: true). DMs always get a response."
+    prompt: "Require mention in rooms? (true/false)"
+    password: false
+  - name: CHATTO_AUTO_THREAD
+    description: "Auto-create threads for replies in rooms (default: true). Set false to reply in the room timeline."
+    prompt: "Auto-thread replies? (true/false)"
+    password: false
+  - name: CHATTO_FREE_RESPONSE_CHANNELS
+    description: "Comma-separated room IDs where the bot responds without being tagged"
+    prompt: "Free-response room IDs (comma-separated)"
+    password: false

+ 2122 - 0
plugins/platforms/chatto/test_adapter.py

@@ -0,0 +1,2122 @@
+"""Unit tests for the Chatto platform adapter.
+
+Covers:
+  - Protobuf codec (varint, tag, fields, client/server frames, projections)
+  - Emoji shortcode conversion
+  - Adapter instantiation and properties
+  - Registration and requirements
+  - Send / reactions / edit / delete (mocked RPC)
+  - Typing indicator lifecycle
+  - Read state and notifications
+  - DM initiation and room creation
+  - User lookup (with caching)
+  - Presence and custom status
+  - Message dispatch (self-echo suppression, handler invocation)
+  - Attachment upload (chunked)
+
+All network calls are mocked — no real HTTP or WebSocket connections.
+"""
+
+import asyncio
+import hashlib
+import os
+import sys
+import tempfile
+from unittest.mock import AsyncMock, MagicMock, patch, call
+from collections import OrderedDict
+
+import pytest
+import pytest_asyncio
+
+# ── Path setup ────────────────────────────────────────────────────────────
+sys.path.insert(0, "/opt/hermes")
+sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
+
+import adapter as chatto_adapter
+from adapter import (
+    _encode_varint,
+    _decode_varint,
+    _encode_tag,
+    _encode_field_varint,
+    _encode_field_bytes,
+    _encode_field_string,
+    _encode_submessage,
+    _decode_fields,
+    _get_first,
+    _get_all,
+    _encode_client_hello,
+    _encode_subscribe_events,
+    _encode_ping,
+    _encode_client_frame_hello,
+    _encode_client_frame_subscribe,
+    _encode_client_frame_ping,
+    _decode_server_frame,
+    _decode_projection_event,
+    _decode_projection_operation,
+    _decode_room_timeline_event,
+    _decode_room_timeline_event_upsert,
+    _decode_message_posted,
+    _decode_message,
+    _decode_thread,
+    _decode_timestamp,
+    _decode_event_envelope,
+    _decode_mention_notification,
+    _decode_dm_notification,
+    _decode_server_hello,
+    _decode_error,
+    _EMOJI_TO_SHORTCODE,
+    _REALTIME_PROTOCOL_VERSION,
+    _MAX_MESSAGE_LENGTH,
+    _SEEN_CAP,
+    ChattoAdapter,
+    check_requirements,
+    validate_config,
+    register,
+)
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import SendResult, MessageEvent, MessageType
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────
+
+class _MockPluginContext:
+    """Minimal mock for the plugin registration context."""
+
+    def __init__(self):
+        self.registered_names = []
+        self.registered_kwargs = None
+
+    def register_platform(self, **kwargs):
+        from gateway.platform_registry import platform_registry, PlatformEntry
+
+        entry = PlatformEntry(
+            name=kwargs["name"],
+            label=kwargs.get("label", kwargs["name"]),
+            adapter_factory=kwargs.get("adapter_factory"),
+            check_fn=kwargs.get("check_fn"),
+            validate_config=kwargs.get("validate_config"),
+            is_connected=kwargs.get("is_connected"),
+            required_env=kwargs.get("required_env", []),
+            source="plugin",
+        )
+        platform_registry.register(entry)
+        self.registered_names.append(kwargs["name"])
+        self.registered_kwargs = kwargs
+
+
+def _ensure_chatto_registered():
+    """Register chatto in the platform registry so Platform('chatto') works."""
+    from gateway.platform_registry import platform_registry
+
+    if not platform_registry.is_registered("chatto"):
+        ctx = _MockPluginContext()
+        register(ctx)
+
+
+_CHATTO_ENV_KEYS = [
+    "CHATTO_URL",
+    "CHATTO_LOGIN",
+    "CHATTO_PASSWORD",
+    "CHATTO_CHANNELS",
+    "CHATTO_HOME_CHANNEL",
+    "CHATTO_REQUIRE_MENTION",
+    "CHATTO_ALLOWED_USERS",
+    "CHATTO_ALLOW_ALL_USERS",
+]
+
+
+def _clear_chatto_env(monkeypatch=None):
+    """Remove all CHATTO_* env vars so tests start from a clean slate."""
+    for key in _CHATTO_ENV_KEYS:
+        if monkeypatch is not None:
+            monkeypatch.delenv(key, raising=False)
+        else:
+            os.environ.pop(key, None)
+
+
+def _make_config(**extra_overrides):
+    """Create a minimal PlatformConfig for testing."""
+    _ensure_chatto_registered()
+    extra = {"url": "https://chat.example.com", "channels": ["room1"]}
+    extra.update(extra_overrides)
+    return PlatformConfig(enabled=True, extra=extra)
+
+
+def _make_adapter(**extra_overrides):
+    """Create a ChattoAdapter with mocked config.  Token is pre-set to avoid
+    _ensure_token triggering a real login.  All CHATTO_* env vars are cleared
+    first so the config.extra values are not overridden by the environment."""
+    _clear_chatto_env()
+    cfg = _make_config(**extra_overrides)
+    adapter = ChattoAdapter(cfg)
+    adapter._token = "test-token"
+    adapter._user_id = "bot-user-id"
+    adapter._user_login = "hermes_bot"
+    adapter._user_display = "Hermes Bot"
+    return adapter
+
+
+# ── Protobuf codec: varint ────────────────────────────────────────────────
+
+
+class TestVarint:
+    """Test _encode_varint / _decode_varint roundtrips."""
+
+    @pytest.mark.parametrize(
+        "value",
+        [0, 1, 127, 128, 16384, 2**32, 2**63 - 1],
+    )
+    def test_varint_roundtrip(self, value):
+        encoded = _encode_varint(value)
+        decoded, offset = _decode_varint(encoded, 0)
+        assert decoded == value
+        assert offset == len(encoded)
+
+    def test_varint_zero(self):
+        assert _encode_varint(0) == b"\x00"
+
+    def test_varint_one(self):
+        assert _encode_varint(1) == b"\x01"
+
+    def test_varint_127(self):
+        assert _encode_varint(127) == b"\x7f"
+
+    def test_varint_128(self):
+        assert _encode_varint(128) == b"\x80\x01"
+
+    def test_varint_300(self):
+        # 300 = 0b100101100 → 0xAC 0x02
+        assert _encode_varint(300) == b"\xac\x02"
+
+    def test_varint_16384(self):
+        # 16384 = 0x4000 → 0x80 0x80 0x01
+        assert _encode_varint(16384) == b"\x80\x80\x01"
+
+    def test_decode_varint_truncated(self):
+        with pytest.raises(ValueError, match="Truncated"):
+            _decode_varint(b"\x80", 0)
+
+    def test_decode_varint_too_long(self):
+        # 10 continuation bytes — exceeds 64-bit
+        with pytest.raises(ValueError, match="too long"):
+            _decode_varint(b"\x80" * 10, 0)
+
+
+# ── Protobuf codec: tag and field encoders ────────────────────────────────
+
+
+class TestTagAndFields:
+    """Test tag encoding and field-level helpers."""
+
+    def test_encode_tag_field1_varint(self):
+        # field 1, wire type 0 → (1<<3)|0 = 8 → 0x08
+        assert _encode_tag(1, 0) == b"\x08"
+
+    def test_encode_tag_field2_length_delimited(self):
+        # field 2, wire type 2 → (2<<3)|2 = 18 → 0x12
+        assert _encode_tag(2, 2) == b"\x12"
+
+    def test_encode_tag_field15_varint(self):
+        # field 15, wire type 0 → (15<<3)|0 = 120 → 0x78
+        assert _encode_tag(15, 0) == b"\x78"
+
+    def test_encode_field_varint(self):
+        result = _encode_field_varint(1, 150)
+        # tag(1,0)=0x08 + varint(150)=0x96 0x01
+        assert result == b"\x08\x96\x01"
+
+    def test_encode_field_bytes(self):
+        result = _encode_field_bytes(2, b"hello")
+        # tag(2,2)=0x12 + len(5)=0x05 + "hello"
+        assert result == b"\x12\x05hello"
+
+    def test_encode_field_string(self):
+        result = _encode_field_string(3, "hi")
+        # tag(3,2)=0x1a + len(2)=0x02 + "hi"
+        assert result == b"\x1a\x02hi"
+
+    def test_encode_submessage(self):
+        inner = _encode_field_varint(1, 42)
+        result = _encode_submessage(5, inner)
+        # The submessage is length-delimited
+        fields = _decode_fields(result)
+        assert 5 in fields
+        assert isinstance(fields[5][0], bytes)
+
+    def test_decode_fields_varint(self):
+        data = _encode_field_varint(1, 42)
+        fields = _decode_fields(data)
+        assert fields[1] == [42]
+
+    def test_decode_fields_bytes(self):
+        data = _encode_field_bytes(2, b"test")
+        fields = _decode_fields(data)
+        assert fields[2] == [b"test"]
+
+    def test_decode_fields_string(self):
+        data = _encode_field_string(3, "hello")
+        fields = _decode_fields(data)
+        assert fields[3] == [b"hello"]
+
+    def test_decode_fields_multiple(self):
+        data = _encode_field_varint(1, 10) + _encode_field_string(2, "abc")
+        fields = _decode_fields(data)
+        assert fields[1] == [10]
+        assert fields[2] == [b"abc"]
+
+    def test_decode_fields_repeated(self):
+        data = _encode_field_string(2, "a") + _encode_field_string(2, "b")
+        fields = _decode_fields(data)
+        assert fields[2] == [b"a", b"b"]
+
+    def test_decode_fields_empty(self):
+        fields = _decode_fields(b"")
+        assert fields == {}
+
+    def test_get_first(self):
+        fields = {1: [10, 20], 2: [b"x"]}
+        assert _get_first(fields, 1) == 10
+        assert _get_first(fields, 2) == b"x"
+        assert _get_first(fields, 99, "default") == "default"
+
+    def test_get_all(self):
+        fields = {1: [10, 20]}
+        assert _get_all(fields, 1) == [10, 20]
+        assert _get_all(fields, 99) == []
+
+
+# ── Protobuf codec: client hello / subscribe / ping ──────────────────────
+
+
+class TestClientFrames:
+    """Test client-side protobuf message encoders."""
+
+    def test_encode_client_hello_with_token(self):
+        msg = _encode_client_hello("my-bearer-token")
+        fields = _decode_fields(msg)
+        # field 1 = protocol_version (varint)
+        assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
+        # field 2 = bearer_token (bytes)
+        token_val = _get_first(fields, 2)
+        assert isinstance(token_val, bytes)
+        assert token_val.decode("utf-8") == "my-bearer-token"
+
+    def test_encode_client_hello_without_token(self):
+        msg = _encode_client_hello("")
+        fields = _decode_fields(msg)
+        assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
+        assert 2 not in fields  # no bearer_token field
+
+    def test_encode_client_hello_protocol_version_is_1(self):
+        msg = _encode_client_hello("x")
+        fields = _decode_fields(msg)
+        assert _get_first(fields, 1) == 1
+
+    def test_encode_subscribe_events_with_rooms(self):
+        msg = _encode_subscribe_events(retained_room_ids=["room1", "room2"])
+        fields = _decode_fields(msg)
+        # field 2 = repeated string
+        room_vals = _get_all(fields, 2)
+        assert len(room_vals) == 2
+        assert room_vals[0].decode("utf-8") == "room1"
+        assert room_vals[1].decode("utf-8") == "room2"
+
+    def test_encode_subscribe_events_with_cursor(self):
+        msg = _encode_subscribe_events(resume_cursor="cursor123")
+        fields = _decode_fields(msg)
+        cursor = _get_first(fields, 1)
+        assert isinstance(cursor, bytes)
+        assert cursor.decode("utf-8") == "cursor123"
+
+    def test_encode_subscribe_events_empty(self):
+        msg = _encode_subscribe_events()
+        assert msg == b""
+
+    def test_encode_ping_is_empty(self):
+        assert _encode_ping() == b""
+
+    def test_encode_client_frame_hello(self):
+        hello = _encode_client_hello("token")
+        frame = _encode_client_frame_hello(hello)
+        fields = _decode_fields(frame)
+        # field 1 = hello submessage
+        assert 1 in fields
+        inner = _get_first(fields, 1)
+        assert isinstance(inner, bytes)
+        # Decode inner to verify
+        inner_fields = _decode_fields(inner)
+        assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
+
+    def test_encode_client_frame_subscribe(self):
+        sub = _encode_subscribe_events(retained_room_ids=["r1"])
+        frame = _encode_client_frame_subscribe(sub)
+        fields = _decode_fields(frame)
+        # field 2 = subscribe_events submessage
+        assert 2 in fields
+
+    def test_encode_client_frame_ping(self):
+        ping = _encode_ping()
+        frame = _encode_client_frame_ping(ping)
+        fields = _decode_fields(frame)
+        # field 3 = ping submessage (empty)
+        assert 3 in fields
+
+    def test_client_hello_roundtrip(self):
+        """Encode a client hello, wrap in a frame, decode the frame, decode
+        the inner hello, and verify values match."""
+        original = _encode_client_hello("roundtrip-token")
+        frame = _encode_client_frame_hello(original)
+        decoded_frame = _decode_server_frame  # not for client frames, but
+        # We decode the frame manually
+        frame_fields = _decode_fields(frame)
+        inner_bytes = _get_first(frame_fields, 1)
+        inner_fields = _decode_fields(inner_bytes)
+        assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
+        token_raw = _get_first(inner_fields, 2)
+        assert token_raw.decode("utf-8") == "roundtrip-token"
+
+
+# ── Protobuf codec: server frame decoding ────────────────────────────────
+
+
+class TestServerFrameDecoding:
+    """Test _decode_server_frame with synthetic frames."""
+
+    def test_decode_server_hello_frame(self):
+        # Build a RealtimeServerHello { protocol_version = 1 }
+        hello_inner = _encode_field_varint(1, 1)
+        # Wrap in RealtimeServerFrame { hello = 1 }
+        frame = _encode_submessage(1, hello_inner)
+        result = _decode_server_frame(frame)
+        assert result["type"] == "hello"
+        assert isinstance(result["data"], bytes)
+
+        # Decode the hello data
+        hello = _decode_server_hello(result["data"])
+        assert hello["protocolVersion"] == 1
+
+    def test_decode_subscribed_frame(self):
+        # Build an empty submessage for field 2 (subscribed)
+        frame = _encode_submessage(2, b"")
+        result = _decode_server_frame(frame)
+        assert result["type"] == "subscribed"
+
+    def test_decode_error_frame(self):
+        # Build RealtimeError { message = "bad", code = 500 }
+        error_inner = _encode_field_string(1, "bad") + _encode_field_varint(2, 500)
+        # Wrap in RealtimeServerFrame { error = 5 }
+        frame = _encode_submessage(5, error_inner)
+        result = _decode_server_frame(frame)
+        assert result["type"] == "error"
+        decoded = _decode_error(result["data"])
+        assert decoded["message"] == "bad"
+        assert decoded["code"] == 500
+
+    def test_decode_pong_frame(self):
+        frame = _encode_submessage(7, b"")
+        result = _decode_server_frame(frame)
+        assert result["type"] == "pong"
+
+    def test_decode_heartbeat_frame(self):
+        frame = _encode_submessage(4, b"")
+        result = _decode_server_frame(frame)
+        assert result["type"] == "heartbeat"
+
+    def test_decode_close_frame(self):
+        close_inner = _encode_field_string(1, "bye")
+        frame = _encode_submessage(6, close_inner)
+        result = _decode_server_frame(frame)
+        assert result["type"] == "close"
+
+    def test_decode_caught_up_frame(self):
+        frame = _encode_submessage(8, b"")
+        result = _decode_server_frame(frame)
+        assert result["type"] == "caught_up"
+
+    def test_decode_unknown_frame(self):
+        # Empty frame → no fields → unknown
+        result = _decode_server_frame(b"")
+        assert result["type"] == "unknown"
+        assert result["data"] is None
+
+
+# ── Protobuf codec: projection event decoding ────────────────────────────
+
+
+class TestProjectionEventDecoding:
+    """Test _decode_projection_event and related decoders."""
+
+    def test_decode_projection_event_basic(self):
+        # Build RealtimeProjectionEvent {
+        #   id = "evt1",
+        #   actor_id = "user1",
+        #   resume_cursor = "cursor1"
+        # }
+        proj = (
+            _encode_field_string(1, "evt1")
+            + _encode_field_string(3, "user1")
+            + _encode_field_string(4, "cursor1")
+        )
+        result = _decode_projection_event(proj)
+        assert result["id"] == "evt1"
+        assert result["actor_id"] == "user1"
+        assert result["resume_cursor"] == "cursor1"
+        assert result["operations"] == []
+
+    def test_decode_projection_event_with_timestamp(self):
+        ts_inner = _encode_field_varint(1, 1700000000)  # seconds
+        proj = (
+            _encode_field_string(1, "evt2")
+            + _encode_submessage(2, ts_inner)
+        )
+        result = _decode_projection_event(proj)
+        assert result["id"] == "evt2"
+        assert "1700000000" not in result["created_at"]  # should be ISO format
+        assert "T" in result["created_at"]  # ISO format has T separator
+
+    def test_decode_projection_event_with_operation(self):
+        # Build a room_timeline_event_upsert operation
+        room_id = _encode_field_string(1, "room123")
+        # Build a minimal RoomTimelineEvent
+        event_inner = _encode_field_string(1, "evt456")
+        event_upsert = room_id + _encode_submessage(2, event_inner)
+        # Wrap in RealtimeProjectionOperation { room_timeline_event_upsert = 10 }
+        op = _encode_submessage(10, event_upsert)
+        # Wrap in RealtimeProjectionEvent { operations = 5 }
+        proj = _encode_field_string(1, "proj1") + _encode_submessage(5, op)
+
+        result = _decode_projection_event(proj)
+        assert len(result["operations"]) == 1
+        op_result = result["operations"][0]
+        assert op_result["type"] == "room_timeline_event_upsert"
+        assert op_result["room_id"] == "room123"
+        assert op_result["event"]["id"] == "evt456"
+
+    def test_decode_projection_operation_unknown(self):
+        # An operation with field 1 (room_upsert) — not handled specifically
+        op = _encode_submessage(1, _encode_field_string(1, "room1"))
+        result = _decode_projection_operation(op)
+        assert result["type"] == "room_upsert"
+
+    def test_decode_projection_operation_empty(self):
+        result = _decode_projection_operation(b"")
+        assert result["type"] == "empty"
+
+
+# ── Protobuf codec: room timeline event decoding ─────────────────────────
+
+
+class TestRoomTimelineEventDecoding:
+    """Test _decode_room_timeline_event and _decode_message."""
+
+    def test_decode_room_timeline_event_basic(self):
+        # Build RoomTimelineEvent {
+        #   id = "evt1", room_id = "room1", kind = 1 (message_posted)
+        # }
+        ts_inner = _encode_field_varint(1, 1700000000)
+        event = (
+            _encode_field_string(1, "evt1")
+            + _encode_submessage(2, ts_inner)
+            + _encode_field_string(3, "room1")
+            + _encode_field_varint(4, 1)
+        )
+        result = _decode_room_timeline_event(event)
+        assert result["id"] == "evt1"
+        assert result["roomId"] == "room1"
+        assert result["kind"] == 1
+        assert "T" in result["createdAt"]
+
+    def test_decode_room_timeline_event_with_message(self):
+        # Build a Message { id="m1", room_id="r1", actor_id="u1", body="hello" }
+        ts_inner = _encode_field_varint(1, 1700000000)
+        msg = (
+            _encode_field_string(1, "m1")
+            + _encode_field_string(2, "r1")
+            + _encode_field_string(3, "u1")
+            + _encode_field_string(4, "hello")
+            + _encode_submessage(5, ts_inner)
+        )
+        # Wrap in MessagePosted { message = 1 }
+        posted = _encode_submessage(1, msg)
+        # Wrap in RoomTimelineEvent { message_posted = 5 }
+        event = (
+            _encode_field_string(1, "evt1")
+            + _encode_field_string(3, "r1")
+            + _encode_submessage(5, posted)
+        )
+        result = _decode_room_timeline_event(event)
+        assert result["id"] == "evt1"
+        assert result["messagePosted"] is not None
+        assert result["messagePosted"]["message"]["id"] == "m1"
+        assert result["messagePosted"]["message"]["body"] == "hello"
+        assert result["messagePosted"]["message"]["actorId"] == "u1"
+
+    def test_decode_message_with_thread(self):
+        # Build Message with a thread
+        thread_inner = _encode_field_string(1, "thread-root-123")
+        msg = (
+            _encode_field_string(1, "m1")
+            + _encode_field_string(2, "r1")
+            + _encode_field_string(3, "u1")
+            + _encode_field_string(4, "threaded reply")
+            + _encode_submessage(8, thread_inner)
+        )
+        result = _decode_message(msg)
+        assert result["id"] == "m1"
+        assert result["body"] == "threaded reply"
+        assert result["thread"]["threadRootEventId"] == "thread-root-123"
+
+    def test_decode_message_with_login_and_display(self):
+        msg = (
+            _encode_field_string(1, "m1")
+            + _encode_field_string(2, "r1")
+            + _encode_field_string(3, "u1")
+            + _encode_field_string(4, "hi")
+            + _encode_field_string(6, "alice")
+            + _encode_field_string(7, "Alice Smith")
+        )
+        result = _decode_message(msg)
+        assert result["actorLogin"] == "alice"
+        assert result["actorDisplayName"] == "Alice Smith"
+
+    def test_decode_thread(self):
+        thread = _encode_field_string(1, "thread-abc")
+        result = _decode_thread(thread)
+        assert result["threadRootEventId"] == "thread-abc"
+
+    def test_decode_thread_empty(self):
+        result = _decode_thread(b"")
+        assert result["threadRootEventId"] == ""
+
+    def test_decode_message_posted_empty(self):
+        result = _decode_message_posted(b"")
+        assert result == {"message": {}}
+
+
+# ── Protobuf codec: timestamp decoding ────────────────────────────────────
+
+
+class TestTimestampDecoding:
+    """Test _decode_timestamp."""
+
+    def test_decode_timestamp_basic(self):
+        ts = _encode_field_varint(1, 1700000000)
+        result = _decode_timestamp(ts)
+        assert "2023" in result  # Nov 14, 2023
+        assert result.endswith("Z")
+
+    def test_decode_timestamp_with_nanos(self):
+        ts = _encode_field_varint(1, 1700000000) + _encode_field_varint(2, 500000)
+        result = _decode_timestamp(ts)
+        assert "2023" in result
+        # 500000 nanos = 0.5 seconds → microsecond=500000 → ".000500" in ISO
+        assert ".000500" in result
+
+    def test_decode_timestamp_zero(self):
+        ts = b""
+        result = _decode_timestamp(ts)
+        assert result == ""
+
+    def test_decode_timestamp_only_nanos(self):
+        # No seconds, just nanos — should return "" since seconds == 0
+        ts = _encode_field_varint(2, 1000000)
+        result = _decode_timestamp(ts)
+        assert result == ""
+
+
+# ── Protobuf codec: event envelope (transient events) ────────────────────
+
+
+class TestEventEnvelopeDecoding:
+    """Test _decode_event_envelope for mention and DM notifications."""
+
+    def test_decode_mention_notification(self):
+        # Build MentionNotification { room_id = "r1", event_id = "e1" }
+        mention_inner = _encode_field_string(1, "r1") + _encode_field_string(2, "e1")
+        # Wrap in RealtimeEventEnvelope { mention_notification = 88 }
+        envelope = (
+            _encode_field_string(1, "envelope1")
+            + _encode_submessage(88, mention_inner)
+        )
+        result = _decode_event_envelope(envelope)
+        assert result["id"] == "envelope1"
+        assert result["type"] == "mention_notification"
+        assert result["data"]["roomId"] == "r1"
+        assert result["data"]["eventId"] == "e1"
+
+    def test_decode_dm_notification(self):
+        # Build NewDirectMessageNotification { room_id = "r2", event_id = "e2" }
+        dm_inner = _encode_field_string(1, "r2") + _encode_field_string(2, "e2")
+        # Wrap in RealtimeEventEnvelope { new_direct_message_notification = 89 }
+        envelope = (
+            _encode_field_string(1, "envelope2")
+            + _encode_submessage(89, dm_inner)
+        )
+        result = _decode_event_envelope(envelope)
+        assert result["id"] == "envelope2"
+        assert result["type"] == "new_direct_message_notification"
+        assert result["data"]["roomId"] == "r2"
+        assert result["data"]["eventId"] == "e2"
+
+    def test_decode_event_envelope_unknown(self):
+        envelope = _encode_field_string(1, "env3")
+        result = _decode_event_envelope(envelope)
+        assert result["id"] == "env3"
+        assert result["type"] == "unknown"
+        assert result["data"] == {}
+
+    def test_decode_mention_notification_directly(self):
+        mention_inner = _encode_field_string(1, "roomX") + _encode_field_string(2, "evtX")
+        result = _decode_mention_notification(mention_inner)
+        assert result["roomId"] == "roomX"
+        assert result["eventId"] == "evtX"
+
+    def test_decode_dm_notification_directly(self):
+        dm_inner = _encode_field_string(1, "roomY") + _encode_field_string(2, "evtY")
+        result = _decode_dm_notification(dm_inner)
+        assert result["roomId"] == "roomY"
+        assert result["eventId"] == "evtY"
+
+
+# ── Emoji shortcode conversion ───────────────────────────────────────────
+
+
+class TestEmojiShortcode:
+    """Test _emoji_to_shortcode static method and the emoji mapping."""
+
+    @pytest.mark.parametrize(
+        "emoji,shortcode",
+        [
+            ("👍", "thumbsup"),
+            ("👎", "thumbsdown"),
+            ("❤️", "heart"),
+            ("❤", "heart"),
+            ("✅", "white_check_mark"),
+            ("❌", "x"),
+            ("👀", "eyes"),
+            ("🎉", "tada"),
+            ("😂", "joy"),
+            ("🚀", "rocket"),
+            ("🔥", "fire"),
+            ("💯", "100"),
+            ("🤔", "thinking"),
+            ("👏", "clap"),
+            ("🙏", "pray"),
+            ("😅", "sweat_smile"),
+            ("😴", "sleeping"),
+            ("⏳", "hourglass"),
+        ],
+    )
+    def test_known_emoji_to_shortcode(self, emoji, shortcode):
+        assert ChattoAdapter._emoji_to_shortcode(emoji) == shortcode
+
+    def test_unknown_emoji_passes_through(self):
+        # 🦀 (crab) is not in the mapping
+        assert ChattoAdapter._emoji_to_shortcode("🦀") == "🦀"
+
+    def test_shortcode_passes_through(self):
+        assert ChattoAdapter._emoji_to_shortcode("thumbsup") == "thumbsup"
+
+    def test_empty_string_passes_through(self):
+        assert ChattoAdapter._emoji_to_shortcode("") == ""
+
+    def test_emoji_mapping_completeness(self):
+        """Verify the mapping dict has the expected keys."""
+        assert "👍" in _EMOJI_TO_SHORTCODE
+        assert _EMOJI_TO_SHORTCODE["👍"] == "thumbsup"
+        assert _EMOJI_TO_SHORTCODE["❤️"] == "heart"
+
+
+# ── Adapter instantiation and properties ──────────────────────────────────
+
+
+class TestAdapterInstantiation:
+    """Test ChattoAdapter creation and property values."""
+
+    def test_platform_name(self):
+        adapter = _make_adapter()
+        assert adapter.platform_name == "chatto"
+
+    def test_supports_markdown(self):
+        adapter = _make_adapter()
+        assert adapter.supports_markdown is True
+
+    def test_supports_reactions(self):
+        adapter = _make_adapter()
+        assert adapter.supports_reactions is True
+
+    def test_supports_threads(self):
+        adapter = _make_adapter()
+        assert adapter.supports_threads is True
+
+    def test_max_message_length(self):
+        adapter = _make_adapter()
+        assert adapter.MAX_MESSAGE_LENGTH == 10000
+
+    def test_splits_long_messages(self):
+        adapter = _make_adapter()
+        assert adapter.splits_long_messages is True
+
+    def test_typing_tasks_empty(self):
+        adapter = _make_adapter()
+        assert adapter._typing_tasks == {}
+
+    def test_user_cache_empty(self):
+        adapter = _make_adapter()
+        assert adapter._user_cache == {}
+
+    def test_base_url_from_extra(self):
+        adapter = _make_adapter()
+        assert adapter._base_url == "https://chat.example.com"
+
+    def test_channels_from_extra(self):
+        adapter = _make_adapter()
+        assert adapter._channel_ids == ["room1"]
+
+    def test_require_mention_default(self):
+        adapter = _make_adapter(require_mention=False)
+        assert adapter._require_mention is False
+
+    def test_is_base_platform_adapter(self):
+        from gateway.platforms.base import BasePlatformAdapter
+
+        adapter = _make_adapter()
+        assert isinstance(adapter, BasePlatformAdapter)
+
+
+# ── Registration and requirements ─────────────────────────────────────────
+
+
+class TestRegistration:
+    """Test plugin registration and requirements checking."""
+
+    def test_register_calls_register_platform(self):
+        ctx = _MockPluginContext()
+        register(ctx)
+        assert "chatto" in ctx.registered_names
+        kwargs = ctx.registered_kwargs
+        assert kwargs["name"] == "chatto"
+        assert kwargs["label"] == "Chatto"
+        assert callable(kwargs["adapter_factory"])
+        assert callable(kwargs["check_fn"])
+
+    def test_register_adapter_factory_creates_adapter(self):
+        ctx = _MockPluginContext()
+        register(ctx)
+        kwargs = ctx.registered_kwargs
+        cfg = _make_config()
+        adapter = kwargs["adapter_factory"](cfg)
+        assert adapter is not None
+        assert isinstance(adapter, ChattoAdapter)
+
+    def test_check_requirements_true_when_env_set(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
+        monkeypatch.setenv("CHATTO_LOGIN", "user")
+        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
+        assert check_requirements() is True
+
+    def test_check_requirements_false_when_url_missing(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_URL", raising=False)
+        monkeypatch.setenv("CHATTO_LOGIN", "user")
+        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
+        assert check_requirements() is False
+
+    def test_check_requirements_false_when_login_missing(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
+        monkeypatch.delenv("CHATTO_LOGIN", raising=False)
+        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
+        assert check_requirements() is False
+
+    def test_check_requirements_false_when_password_missing(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
+        monkeypatch.setenv("CHATTO_LOGIN", "user")
+        monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
+        assert check_requirements() is False
+
+    def test_check_requirements_false_all_missing(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_URL", raising=False)
+        monkeypatch.delenv("CHATTO_LOGIN", raising=False)
+        monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
+        assert check_requirements() is False
+
+    def test_validate_config_with_extra_url(self):
+        cfg = MagicMock()
+        cfg.extra = {"url": "https://chat.example.com"}
+        # Also need login/password from env
+        with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}):
+            assert validate_config(cfg) is True
+
+    def test_validate_config_missing_url(self):
+        cfg = MagicMock()
+        cfg.extra = {}
+        with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}, clear=False):
+            # Remove CHATTO_URL if set
+            os.environ.pop("CHATTO_URL", None)
+            assert validate_config(cfg) is False
+
+
+# ── Send method (mocked RPC) ──────────────────────────────────────────────
+
+
+class TestSend:
+    """Test the send() method with mocked _rpc."""
+
+    @pytest.mark.asyncio
+    async def test_send_basic(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt123"}}))
+        result = await adapter.send("room1", "Hello world")
+        assert result.success is True
+        assert result.message_id == "evt123"
+
+    @pytest.mark.asyncio
+    async def test_send_with_reply_to(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt456"}}))
+        adapter._follow_thread = AsyncMock()
+        await adapter.send("room1", "Reply", reply_to="thread-root-1")
+        # Verify RPC body contains threadRootEventId
+        call_args = adapter._rpc.call_args
+        body = call_args.kwargs.get("body") or call_args.args[1]
+        assert body["threadRootEventId"] == "thread-root-1"
+
+    @pytest.mark.asyncio
+    async def test_send_with_metadata_thread_id(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt789"}}))
+        adapter._follow_thread = AsyncMock()
+        await adapter.send("room1", "Reply", metadata={"thread_id": "thread456"})
+        call_args = adapter._rpc.call_args
+        body = call_args.kwargs.get("body") or call_args.args[1]
+        assert body["threadRootEventId"] == "thread456"
+
+    @pytest.mark.asyncio
+    async def test_send_empty_content(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock()
+        result = await adapter.send("room1", "")
+        assert result.success is False
+        assert "Empty" in (result.error or "")
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_send_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {"error": "server error"}))
+        result = await adapter.send("room1", "Hello")
+        assert result.success is False
+        assert "server error" in (result.error or "")
+
+    @pytest.mark.asyncio
+    async def test_send_long_message_split(self):
+        adapter = _make_adapter()
+        # Return different message IDs for each call
+        call_count = [0]
+
+        async def mock_rpc(path, body, **kwargs):
+            call_count[0] += 1
+            return 200, {"message": {"id": f"evt-{call_count[0]}"}}
+
+        adapter._rpc = AsyncMock(side_effect=mock_rpc)
+        adapter._follow_thread = AsyncMock()
+        # Create content longer than MAX_MESSAGE_LENGTH (10000)
+        long_content = "A" * 12000
+        result = await adapter.send("room1", long_content)
+        assert result.success is True
+        assert result.message_id == "evt-1"
+        # Should have been called multiple times
+        assert adapter._rpc.call_count > 1
+
+    @pytest.mark.asyncio
+    async def test_send_marks_seen(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "new_evt"}}))
+        await adapter.send("room1", "Hello")
+        assert "new_evt" in adapter._seen.get("room1", {})
+
+
+# ── Reactions (mocked RPC) ────────────────────────────────────────────────
+
+
+class TestReactions:
+    """Test send_reaction and remove_reaction."""
+
+    @pytest.mark.asyncio
+    async def test_send_reaction_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"added": True}))
+        result = await adapter.send_reaction("room1", "evt1", "👍")
+        assert result is True
+        # Verify emoji was converted to shortcode
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["emoji"] == "thumbsup"
+
+    @pytest.mark.asyncio
+    async def test_send_reaction_heart(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        await adapter.send_reaction("room1", "evt1", "❤️")
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["emoji"] == "heart"
+
+    @pytest.mark.asyncio
+    async def test_remove_reaction_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.remove_reaction("room1", "evt1", "❤️")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["emoji"] == "heart"
+
+    @pytest.mark.asyncio
+    async def test_send_reaction_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.send_reaction("room1", "evt1", "👍")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_remove_reaction_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.remove_reaction("room1", "evt1", "❤️")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_send_reaction_exception(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(side_effect=Exception("network error"))
+        result = await adapter.send_reaction("room1", "evt1", "👍")
+        assert result is False
+
+
+# ── Message edit/delete (mocked RPC) ──────────────────────────────────────
+
+
+class TestEditDelete:
+    """Test edit_message and delete_message."""
+
+    @pytest.mark.asyncio
+    async def test_edit_message_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.edit_message("room1", "evt1", "new text")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["body"] == "new text"
+        assert body["eventId"] == "evt1"
+
+    @pytest.mark.asyncio
+    async def test_edit_message_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.edit_message("room1", "evt1", "new text")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_delete_message_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.delete_message("room1", "evt1")
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_delete_message_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.delete_message("room1", "evt1")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_edit_message_exception(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
+        result = await adapter.edit_message("room1", "evt1", "text")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_delete_message_exception(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
+        result = await adapter.delete_message("room1", "evt1")
+        assert result is False
+
+
+# ── Typing indicator lifecycle ─────────────────────────────────────────────
+
+
+class TestTypingIndicator:
+    """Test send_typing and stop_typing lifecycle."""
+
+    @pytest.mark.asyncio
+    async def test_send_typing_creates_task(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        await adapter.send_typing("room1")
+        assert "room1" in adapter._typing_tasks
+        assert isinstance(adapter._typing_tasks["room1"], asyncio.Task)
+        # Clean up
+        await adapter.stop_typing("room1")
+
+    @pytest.mark.asyncio
+    async def test_send_typing_no_duplicate(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        await adapter.send_typing("room1")
+        first_task = adapter._typing_tasks["room1"]
+        await adapter.send_typing("room1")
+        assert adapter._typing_tasks["room1"] is first_task
+        await adapter.stop_typing("room1")
+
+    @pytest.mark.asyncio
+    async def test_stop_typing_cancels_task(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        await adapter.send_typing("room1")
+        assert "room1" in adapter._typing_tasks
+        await adapter.stop_typing("room1")
+        assert "room1" not in adapter._typing_tasks
+
+    @pytest.mark.asyncio
+    async def test_stop_typing_when_not_running(self):
+        adapter = _make_adapter()
+        # Should not raise even if no task exists
+        await adapter.stop_typing("room1")
+
+    @pytest.mark.asyncio
+    async def test_typing_loop_calls_rpc(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        await adapter.send_typing("room1")
+        # Allow the loop to run one iteration
+        await asyncio.sleep(0.05)
+        # The typing loop should have called _rpc at least once
+        assert adapter._rpc.call_count >= 1
+        # Verify it was called with the typing path
+        first_call = adapter._rpc.call_args_list[0]
+        path = first_call.args[0] if len(first_call.args) > 0 else first_call.kwargs.get("path")
+        assert "UpdateTypingIndicator" in path
+        body = first_call.args[1] if len(first_call.args) > 1 else first_call.kwargs.get("body")
+        assert body["roomId"] == "room1"
+        assert body["typing"] is True
+        await adapter.stop_typing("room1")
+
+
+# ── Read state and notifications (mocked RPC) ─────────────────────────────
+
+
+class TestReadStateAndNotifications:
+    """Test mark_room_as_read, mark_thread_as_read, dismiss notifications."""
+
+    @pytest.mark.asyncio
+    async def test_mark_room_as_read_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.mark_room_as_read("room1")
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_mark_room_as_read_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.mark_room_as_read("room1")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_mark_thread_as_read_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.mark_thread_as_read("room1", "thread-root-1")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["roomId"] == "room1"
+        assert body["threadRootEventId"] == "thread-root-1"
+
+    @pytest.mark.asyncio
+    async def test_mark_thread_as_read_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.mark_thread_as_read("room1", "thread-root-1")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_dismiss_all_notifications_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.dismiss_all_notifications()
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_dismiss_all_notifications_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.dismiss_all_notifications()
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_dismiss_notification_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.dismiss_notification("notif123")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["notificationId"] == "notif123"
+
+    @pytest.mark.asyncio
+    async def test_dismiss_notification_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.dismiss_notification("notif123")
+        assert result is False
+
+
+# ── DM initiation and room creation (mocked RPC) ──────────────────────────
+
+
+class TestDMAndRoomCreation:
+    """Test start_dm and create_room."""
+
+    @pytest.mark.asyncio
+    async def test_start_dm_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
+        result = await adapter.start_dm("user123")
+        assert result == "room789"
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["participantIds"] == ["user123"]
+
+    @pytest.mark.asyncio
+    async def test_start_dm_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.start_dm("user123")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_start_dm_no_room_id(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"room": {}}))
+        result = await adapter.start_dm("user123")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_start_dm_sets_room_kind(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "dm-room-1"}}))
+        await adapter.start_dm("user123")
+        assert adapter._room_kinds["dm-room-1"] == "ROOM_KIND_DM"
+
+    @pytest.mark.asyncio
+    async def test_create_room_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
+        result = await adapter.create_room("test-room", "description")
+        assert result == "room789"
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["name"] == "test-room"
+        assert body["description"] == "description"
+
+    @pytest.mark.asyncio
+    async def test_create_room_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.create_room("test-room", "description")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_create_room_sets_room_kind(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "group-room-1"}}))
+        await adapter.create_room("test-room")
+        assert adapter._room_kinds["group-room-1"] == "ROOM_KIND_GROUP"
+        assert adapter._room_names["group-room-1"] == "test-room"
+
+
+# ── User lookup (mocked RPC) ──────────────────────────────────────────────
+
+
+class TestUserLookup:
+    """Test get_user, list_users, batch_get_users with caching."""
+
+    @pytest.mark.asyncio
+    async def test_get_user_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(
+            return_value=(200, {"user": {"id": "u1", "login": "alice", "displayName": "Alice"}})
+        )
+        result = await adapter.get_user("u1")
+        assert result is not None
+        assert result["id"] == "u1"
+        assert result["login"] == "alice"
+
+    @pytest.mark.asyncio
+    async def test_get_user_caches(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(
+            return_value=(200, {"user": {"id": "u1", "login": "alice"}})
+        )
+        await adapter.get_user("u1")
+        # Second call should use cache — no second RPC
+        await adapter.get_user("u1")
+        assert adapter._rpc.call_count == 1
+        assert "u1" in adapter._user_cache
+
+    @pytest.mark.asyncio
+    async def test_get_user_empty_id(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock()
+        result = await adapter.get_user("")
+        assert result is None
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_get_user_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(404, {}))
+        result = await adapter.get_user("u1")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_get_user_exception(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
+        result = await adapter.get_user("u1")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_list_users_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(
+            return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
+        )
+        result = await adapter.list_users()
+        assert len(result) == 2
+        assert result[0]["id"] == "u1"
+        # Should cache all returned users
+        assert "u1" in adapter._user_cache
+        assert "u2" in adapter._user_cache
+
+    @pytest.mark.asyncio
+    async def test_list_users_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.list_users()
+        assert result == []
+
+    @pytest.mark.asyncio
+    async def test_batch_get_users_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(
+            return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
+        )
+        result = await adapter.batch_get_users(["u1", "u2"])
+        assert len(result) == 2
+        # Should cache results
+        assert "u1" in adapter._user_cache
+        assert "u2" in adapter._user_cache
+
+    @pytest.mark.asyncio
+    async def test_batch_get_users_empty_list(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock()
+        result = await adapter.batch_get_users([])
+        assert result == []
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_batch_get_users_uses_cache(self):
+        adapter = _make_adapter()
+        # Pre-populate cache
+        adapter._user_cache["u1"] = {"id": "u1", "login": "alice"}
+        adapter._rpc = AsyncMock(
+            return_value=(200, {"users": [{"id": "u2"}]})
+        )
+        result = await adapter.batch_get_users(["u1", "u2"])
+        assert len(result) == 2
+        # Should have only fetched u2 from server
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["userIds"] == ["u2"]
+
+    @pytest.mark.asyncio
+    async def test_batch_get_users_all_cached(self):
+        adapter = _make_adapter()
+        adapter._user_cache["u1"] = {"id": "u1"}
+        adapter._rpc = AsyncMock()
+        result = await adapter.batch_get_users(["u1"])
+        assert len(result) == 1
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_batch_get_users_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.batch_get_users(["u1"])
+        assert result == []
+
+
+# ── Presence and custom status (mocked RPC) ──────────────────────────────
+
+
+class TestPresenceAndStatus:
+    """Test set_presence, set_custom_status, clear_custom_status."""
+
+    @pytest.mark.asyncio
+    async def test_set_presence_online(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_presence("online")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["status"] == 1
+
+    @pytest.mark.asyncio
+    async def test_set_presence_dnd(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_presence("dnd")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["status"] == 3
+
+    @pytest.mark.asyncio
+    async def test_set_presence_away(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_presence("away")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["status"] == 2
+
+    @pytest.mark.asyncio
+    async def test_set_presence_do_not_disturb(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_presence("do_not_disturb")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["status"] == 3
+
+    @pytest.mark.asyncio
+    async def test_set_presence_unknown(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_presence("invisible")
+        assert result is False
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_set_presence_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.set_presence("online")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_set_custom_status_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.set_custom_status("Processing...")
+        assert result is True
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert body["status"] == "Processing..."
+
+    @pytest.mark.asyncio
+    async def test_set_custom_status_empty(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock()
+        result = await adapter.set_custom_status("")
+        assert result is False
+        adapter._rpc.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_set_custom_status_truncates(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        long_status = "A" * 200
+        await adapter.set_custom_status(long_status)
+        call_args = adapter._rpc.call_args
+        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
+        assert len(body["status"]) == 100
+
+    @pytest.mark.asyncio
+    async def test_set_custom_status_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.set_custom_status("test")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_clear_custom_status_success(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        result = await adapter.clear_custom_status()
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_clear_custom_status_failure(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(500, {}))
+        result = await adapter.clear_custom_status()
+        assert result is False
+
+
+# ── Message dispatch (mocked) ─────────────────────────────────────────────
+
+
+class TestMessageDispatch:
+    """Test _dispatch_message with mocked handler.
+
+    The base class ``handle_message`` does complex session management and
+    spawns background tasks, so we mock it to verify _dispatch_message calls
+    it with the right MessageEvent.
+    """
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_calls_handler(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_names["room1"] = "General"
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = False  # don't require mention for this test
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello bot",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "actorDisplayName": "Alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        assert adapter.handle_message.called
+        event = adapter.handle_message.call_args.args[0]
+        assert event.text == "Hello bot"
+        assert event.message_id == "evt1"
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_self_echo_suppressed(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "bot-user-id",  # matches adapter._user_id
+            "body": "My own message",
+            "createdAt": "",
+            "actorLogin": "hermes_bot",
+            "actorDisplayName": "Hermes Bot",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        adapter.handle_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_empty_body_skipped(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "",
+            "createdAt": "",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        adapter.handle_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_no_handler(self):
+        adapter = _make_adapter()
+        adapter._message_handler = None
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        # Should not raise
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello",
+            "createdAt": "",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_mark_room_read_called(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = False  # don't require mention for this test
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        # mark_room_as_read and dismiss_all_notifications should have been called
+        # via _rpc — check for MarkRoomAsRead and DismissAllNotifications paths
+        rpc_paths = [c.args[0] for c in adapter._rpc.call_args_list if len(c.args) > 0]
+        assert any("MarkRoomAsRead" in p for p in rpc_paths)
+        assert any("DismissAllNotifications" in p for p in rpc_paths)
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_dm_always_responds(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_DM"
+        adapter._require_mention = True  # even with require_mention, DMs respond
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello without mention",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        assert adapter.handle_message.called
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_require_mention_no_mention_skipped(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = True
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello without mention",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        adapter.handle_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_with_mention(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = True
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "@hermes_bot do something",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        assert adapter.handle_message.called
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_with_thread(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = False  # don't require mention for this test
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "Hello",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {"threadRootEventId": "thread-root-123"},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        assert adapter.handle_message.called
+        # Verify the event was created with the thread_id
+        event = adapter.handle_message.call_args.args[0]
+        assert event.source.thread_id == "thread-root-123"
+
+    @pytest.mark.asyncio
+    async def test_dispatch_message_strips_mention_prefix(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = True
+
+        msg = {
+            "id": "evt1",
+            "roomId": "room1",
+            "actorId": "user1",
+            "body": "@hermes_bot please help",
+            "createdAt": "",
+            "actorLogin": "alice",
+            "thread": {},
+        }
+        await adapter._dispatch_message(msg, "room1")
+        event = adapter.handle_message.call_args.args[0]
+        # The mention prefix should be stripped
+        assert not event.text.startswith("@hermes_bot")
+        assert "please help" in event.text
+
+
+# ── Processing lifecycle hooks ─────────────────────────────────────────────
+
+
+class TestProcessingLifecycle:
+    """Test on_processing_start and on_processing_complete reaction hooks."""
+
+    @pytest.mark.asyncio
+    async def test_on_processing_start_adds_eyes(self):
+        adapter = _make_adapter()
+        adapter.send_reaction = AsyncMock(return_value=True)
+        event = MagicMock()
+        event.message_id = "evt1"
+        event.source = MagicMock()
+        event.source.chat_id = "room1"
+        event.raw_message = {}
+        await adapter.on_processing_start(event)
+        adapter.send_reaction.assert_called_once_with("room1", "evt1", "👀")
+
+    @pytest.mark.asyncio
+    async def test_on_processing_complete_success(self):
+        from gateway.platforms.base import ProcessingOutcome
+
+        adapter = _make_adapter()
+        adapter.send_reaction = AsyncMock(return_value=True)
+        adapter.remove_reaction = AsyncMock(return_value=True)
+        event = MagicMock()
+        event.message_id = "evt1"
+        event.source = MagicMock()
+        event.source.chat_id = "room1"
+        event.raw_message = {}
+        await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
+        adapter.remove_reaction.assert_called_once_with("room1", "evt1", "👀")
+        adapter.send_reaction.assert_called_once_with("room1", "evt1", "✅")
+
+    @pytest.mark.asyncio
+    async def test_on_processing_complete_failure(self):
+        from gateway.platforms.base import ProcessingOutcome
+
+        adapter = _make_adapter()
+        adapter.send_reaction = AsyncMock(return_value=True)
+        adapter.remove_reaction = AsyncMock(return_value=True)
+        event = MagicMock()
+        event.message_id = "evt1"
+        event.source = MagicMock()
+        event.source.chat_id = "room1"
+        event.raw_message = {}
+        await adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
+        adapter.send_reaction.assert_called_once_with("room1", "evt1", "❌")
+
+    @pytest.mark.asyncio
+    async def test_reactions_disabled(self, monkeypatch):
+        adapter = _make_adapter()
+        monkeypatch.setenv("CHATTO_REACTIONS", "false")
+        adapter.send_reaction = AsyncMock()
+        event = MagicMock()
+        event.message_id = "evt1"
+        event.source = MagicMock()
+        event.source.chat_id = "room1"
+        event.raw_message = {}
+        await adapter.on_processing_start(event)
+        adapter.send_reaction.assert_not_called()
+
+
+# ── Attachment upload (mocked RPC) ────────────────────────────────────────
+
+
+class TestAttachmentUpload:
+    """Test the chunked asset upload flow."""
+
+    @pytest.mark.asyncio
+    async def test_upload_asset_success(self):
+        adapter = _make_adapter()
+
+        # Create a temporary file
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
+            f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
+            tmp_path = f.name
+
+        try:
+            rpc_responses = [
+                (200, {"upload": {"id": "upload-1"}}),  # CreateUpload
+                (200, {}),  # UploadChunk
+                (200, {"asset": {"id": "asset-1"}}),  # CompleteUpload
+            ]
+            adapter._rpc = AsyncMock(side_effect=rpc_responses)
+
+            asset_id = await adapter._upload_asset("room1", tmp_path)
+            assert asset_id == "asset-1"
+            assert adapter._rpc.call_count == 3
+
+            # Verify the first call was CreateUpload
+            first_call = adapter._rpc.call_args_list[0]
+            path = first_call.args[0]
+            assert "CreateUpload" in path
+            body = first_call.args[1]
+            assert body["roomId"] == "room1"
+            assert "sha256" in body
+
+            # Verify last call was CompleteUpload
+            last_call = adapter._rpc.call_args_list[-1]
+            path = last_call.args[0]
+            assert "CompleteUpload" in path
+        finally:
+            os.unlink(tmp_path)
+
+    @pytest.mark.asyncio
+    async def test_upload_asset_create_fails(self):
+        adapter = _make_adapter()
+
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
+            f.write(b"test data")
+            tmp_path = f.name
+
+        try:
+            adapter._rpc = AsyncMock(return_value=(500, {"error": "fail"}))
+            result = await adapter._upload_asset("room1", tmp_path)
+            assert result is None
+        finally:
+            os.unlink(tmp_path)
+
+    @pytest.mark.asyncio
+    async def test_upload_asset_empty_file(self):
+        adapter = _make_adapter()
+
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
+            f.write(b"")
+            tmp_path = f.name
+
+        try:
+            adapter._rpc = AsyncMock()
+            result = await adapter._upload_asset("room1", tmp_path)
+            assert result is None
+            adapter._rpc.assert_not_called()
+        finally:
+            os.unlink(tmp_path)
+
+    @pytest.mark.asyncio
+    async def test_send_image_file_success(self):
+        adapter = _make_adapter()
+
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
+            f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
+            tmp_path = f.name
+
+        try:
+            rpc_responses = [
+                (200, {"upload": {"id": "upload-1"}}),  # CreateUpload
+                (200, {}),  # UploadChunk
+                (200, {"asset": {"id": "asset-1"}}),  # CompleteUpload
+                (200, {"message": {"id": "msg-1"}}),  # CreateMessage
+            ]
+            adapter._rpc = AsyncMock(side_effect=rpc_responses)
+
+            result = await adapter.send_image_file("room1", tmp_path, caption="Test image")
+            assert result.success is True
+            assert result.message_id == "msg-1"
+
+            # Verify the CreateMessage call had attachmentAssetIds
+            create_msg_call = adapter._rpc.call_args_list[-1]
+            body = create_msg_call.args[1]
+            assert body["attachmentAssetIds"] == ["asset-1"]
+            assert body["body"] == "Test image"
+        finally:
+            os.unlink(tmp_path)
+
+    @pytest.mark.asyncio
+    async def test_send_image_file_upload_fails_fallback(self):
+        adapter = _make_adapter()
+
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
+            f.write(b"\x89PNG" + b"\x00" * 100)
+            tmp_path = f.name
+
+        try:
+            # Upload fails, then fallback send succeeds
+            rpc_responses = [
+                (500, {"error": "fail"}),  # CreateUpload fails
+                (200, {"message": {"id": "fallback-msg"}}),  # Fallback send
+            ]
+            adapter._rpc = AsyncMock(side_effect=rpc_responses)
+
+            result = await adapter.send_image_file("room1", tmp_path)
+            assert result.success is True
+            assert result.message_id == "fallback-msg"
+        finally:
+            os.unlink(tmp_path)
+
+
+# ── Seen tracking ─────────────────────────────────────────────────────────
+
+
+class TestSeenTracking:
+    """Test _mark_seen, _is_seen, and the _SEEN_CAP."""
+
+    def test_mark_and_check_seen(self):
+        adapter = _make_adapter()
+        adapter._mark_seen("room1", "evt1")
+        assert adapter._is_seen("room1", "evt1") is True
+        assert adapter._is_seen("room1", "evt2") is False
+        assert adapter._is_seen("room2", "evt1") is False
+
+    def test_mark_seen_evicts_old_beyond_cap(self):
+        adapter = _make_adapter()
+        # Add more than _SEEN_CAP events
+        for i in range(_SEEN_CAP + 10):
+            adapter._mark_seen("room1", f"evt{i}")
+        # The first events should have been evicted
+        assert adapter._is_seen("room1", "evt0") is False
+        # The most recent should still be there
+        assert adapter._is_seen("room1", f"evt{_SEEN_CAP + 9}") is True
+        # Total should not exceed cap
+        assert len(adapter._seen["room1"]) <= _SEEN_CAP
+
+
+# ── WebSocket URL building ────────────────────────────────────────────────
+
+
+class TestWebSocketURL:
+    """Test _websocket_url conversion."""
+
+    def test_https_to_wss(self):
+        adapter = _make_adapter()
+        adapter._base_url = "https://chat.example.com"
+        url = adapter._websocket_url()
+        assert url.startswith("wss://")
+        assert "/api/realtime" in url
+
+    def test_http_to_ws(self):
+        adapter = _make_adapter()
+        adapter._base_url = "http://localhost:8080"
+        url = adapter._websocket_url()
+        assert url.startswith("ws://")
+        assert "/api/realtime" in url
+
+    def test_websocket_url_with_path(self):
+        adapter = _make_adapter()
+        adapter._base_url = "https://chat.example.com/subpath"
+        url = adapter._websocket_url()
+        assert "/subpath/api/realtime" in url
+
+    def test_websocket_url_invalid_scheme(self):
+        adapter = _make_adapter()
+        adapter._base_url = "ftp://chat.example.com"
+        with pytest.raises(ValueError, match="must use http"):
+            adapter._websocket_url()
+
+
+# ── Get chat info ─────────────────────────────────────────────────────────
+
+
+class TestGetChatInfo:
+    """Test get_chat_info."""
+
+    @pytest.mark.asyncio
+    async def test_get_chat_info_group(self):
+        adapter = _make_adapter()
+        adapter._room_names["room1"] = "General"
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        info = await adapter.get_chat_info("room1")
+        assert info["name"] == "General"
+        assert info["type"] == "group"
+
+    @pytest.mark.asyncio
+    async def test_get_chat_info_dm(self):
+        adapter = _make_adapter()
+        adapter._room_names["room2"] = "Alice"
+        adapter._room_kinds["room2"] = "ROOM_KIND_DM"
+        info = await adapter.get_chat_info("room2")
+        assert info["name"] == "Alice"
+        assert info["type"] == "dm"
+
+    @pytest.mark.asyncio
+    async def test_get_chat_info_unknown_room(self):
+        adapter = _make_adapter()
+        info = await adapter.get_chat_info("unknown-room")
+        assert info["name"] == "unknown-room"
+        assert info["type"] == "group"  # default
+
+
+# ── Handle projection event ──────────────────────────────────────────────
+
+
+class TestHandleProjectionEvent:
+    """Test _handle_projection_event and _handle_timeline_event_upsert."""
+
+    @pytest.mark.asyncio
+    async def test_handle_projection_event_updates_cursor(self):
+        adapter = _make_adapter()
+        # Build a projection event with a resume cursor
+        proj = _encode_field_string(4, "new-cursor-123")
+        await adapter._handle_projection_event(proj)
+        assert adapter._resume_cursor == "new-cursor-123"
+
+    @pytest.mark.asyncio
+    async def test_handle_projection_event_empty_data(self):
+        adapter = _make_adapter()
+        await adapter._handle_projection_event(b"")
+        # Should not raise, cursor unchanged
+
+    @pytest.mark.asyncio
+    async def test_handle_timeline_event_dedup(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+        adapter.handle_message = AsyncMock()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
+        adapter._require_mention = False  # don't require mention for this test
+
+        op = {
+            "room_id": "room1",
+            "event": {
+                "id": "evt-dedup",
+                "messagePosted": {
+                    "message": {
+                        "id": "m1",
+                        "roomId": "room1",
+                        "actorId": "user1",
+                        "body": "Hello",
+                        "createdAt": "",
+                        "thread": {},
+                    }
+                },
+            },
+        }
+        # First call should dispatch
+        await adapter._handle_timeline_event_upsert(op)
+        assert adapter.handle_message.called
+
+        # Reset mock
+        adapter.handle_message.reset_mock()
+
+        # Second call should be deduped
+        await adapter._handle_timeline_event_upsert(op)
+        adapter.handle_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_handle_timeline_event_no_message_posted(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+
+        op = {
+            "room_id": "room1",
+            "event": {
+                "id": "evt1",
+                "messagePosted": None,
+            },
+        }
+        await adapter._handle_timeline_event_upsert(op)
+        adapter._message_handler.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_handle_timeline_event_empty_event(self):
+        adapter = _make_adapter()
+        adapter._message_handler = AsyncMock()
+
+        op = {"room_id": "room1", "event": {}}
+        await adapter._handle_timeline_event_upsert(op)
+        adapter._message_handler.assert_not_called()
+
+
+# ── Handle transient event ────────────────────────────────────────────────
+
+
+class TestHandleTransientEvent:
+    """Test _handle_transient_event."""
+
+    @pytest.mark.asyncio
+    async def test_handle_transient_mention(self):
+        adapter = _make_adapter()
+        # Build a transient event envelope with a mention notification
+        mention_inner = _encode_field_string(1, "room1") + _encode_field_string(2, "evt1")
+        envelope = _encode_field_string(1, "env1") + _encode_submessage(88, mention_inner)
+        # Should not raise
+        await adapter._handle_transient_event(envelope)
+
+    @pytest.mark.asyncio
+    async def test_handle_transient_dm(self):
+        adapter = _make_adapter()
+        dm_inner = _encode_field_string(1, "room2") + _encode_field_string(2, "evt2")
+        envelope = _encode_field_string(1, "env2") + _encode_submessage(89, dm_inner)
+        await adapter._handle_transient_event(envelope)
+
+    @pytest.mark.asyncio
+    async def test_handle_transient_empty(self):
+        adapter = _make_adapter()
+        await adapter._handle_transient_event(b"")
+
+
+# ── Disconnect ────────────────────────────────────────────────────────────
+
+
+class TestDisconnect:
+    """Test disconnect cleanup."""
+
+    @pytest.mark.asyncio
+    async def test_disconnect_cancels_typing_tasks(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._ws_active = False  # avoid websocket teardown
+        adapter._liveness_task = None
+        adapter._ws_task = None
+
+        await adapter.send_typing("room1")
+        assert "room1" in adapter._typing_tasks
+        await adapter.disconnect()
+        assert "room1" not in adapter._typing_tasks
+
+    @pytest.mark.asyncio
+    async def test_disconnect_clears_token(self):
+        adapter = _make_adapter()
+        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._ws_active = False
+        adapter._liveness_task = None
+        adapter._ws_task = None
+        assert adapter._token is not None
+        await adapter.disconnect()
+        assert adapter._token is None

+ 415 - 0
website/docs/user-guide/messaging/chatto.md

@@ -0,0 +1,415 @@
+---
+sidebar_position: 30
+title: "Chatto"
+description: "Set up Hermes Agent as a Chatto bot"
+---
+
+# Chatto Setup
+
+Hermes Agent integrates with [Chatto](https://github.com/chattocorp/chatto), a self-hosted team chat server. The adapter connects via the Chatto WebSocket realtime protocol (binary protobuf) for inbound message delivery and the ConnectRPC JSON API for outbound messages. It processes messages through the Hermes Agent pipeline (including tool use, memory, and reasoning) and responds in real time. It supports text, markdown, threads, reactions, file attachments, typing indicators, and more.
+
+No external Python packages are required beyond `websockets`, which is bundled with Hermes. The adapter includes a pure-stdlib protobuf codec — no `protobuf` library needed.
+
+Before setup, here's the part most people want to know: how Hermes behaves once it's in your Chatto instance.
+
+## How Hermes Behaves
+
+| Context | Behavior |
+|---------|----------|
+| **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
+| **Rooms** | Hermes responds when you `@mention` it. Without a mention, Hermes ignores the message (configurable via `CHATTO_REQUIRE_MENTION`). |
+| **Threads** | Hermes supports thread/reply chains. If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
+| **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success or ❌ on failure. |
+| **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
+| **Message batching** | Long responses (>10000 chars) are automatically split into multiple messages. |
+
+:::tip
+If you want Hermes to respond to all messages in a room without requiring an `@mention`, set `CHATTO_REQUIRE_MENTION=false`. DMs always get a response regardless of this setting.
+:::
+
+## Capability Matrix
+
+| Capability | Chatto |
+|------------|--------|
+| text | yes |
+| markdown | yes |
+| threads | yes |
+| reactions | yes |
+| message editing | yes |
+| message deletion | yes |
+| typing indicators | yes |
+| processing notifications | yes (👀/✅/❌) |
+| file attachments | yes (chunked upload) |
+| DM initiation | yes |
+| room creation | yes |
+| member directory | yes (cached) |
+| presence broadcasting | yes (online/away) |
+| custom status | yes |
+| read state management | yes |
+| auto-reconnect | yes |
+
+## Prerequisites
+
+1. **A running Chatto server** — self-hosted and accessible from the Hermes host. See the [Chatto repository](https://github.com/chattocorp/chatto) for installation instructions.
+2. **A Chatto user account** — the adapter logs in with a username and password. Create a dedicated account for the bot (e.g., `hermes`).
+3. **Room membership** — the bot account must be a member of any room where you want it to respond. The adapter auto-joins rooms specified in `CHATTO_CHANNELS`. For DMs, simply start a direct message with the bot.
+4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`.
+
+:::info
+The adapter uses WebSocket protocol v1 (compatible with Chatto v0.4.19+). Ensure your Chatto server is up to date.
+:::
+
+## Installation
+
+The Chatto adapter is a platform plugin. It lives at:
+
+```
+~/.hermes/plugins/platforms/chatto/
+```
+
+### Option A: Plugin Install (Recommended)
+
+If the plugin is available in a plugin registry:
+
+```bash
+hermes plugins install chatto-platform
+```
+
+### Option B: Manual Installation
+
+Copy the plugin files to the correct location:
+
+```bash
+mkdir -p ~/.hermes/plugins/platforms/chatto
+cp -r chatto-plugin/* ~/.hermes/plugins/platforms/chatto/
+```
+
+The plugin directory should contain:
+
+```
+~/.hermes/plugins/platforms/chatto/
+├── __init__.py
+├── adapter.py          # Main adapter (WebSocket + ConnectRPC)
+├── plugin.yaml         # Plugin manifest (env var definitions)
+└── test_adapter.py     # Unit tests
+```
+
+Hermes auto-discovers platform plugins on gateway startup. No manual registration is needed.
+
+## Configuration
+
+### Option A: Interactive Setup (Recommended)
+
+Run the guided setup command:
+
+```bash
+hermes gateway setup
+```
+
+Select **Chatto** when prompted, then provide your server URL, login, and password when asked.
+
+### Option B: Manual Configuration
+
+Add the following to your `~/.hermes/.env` file:
+
+```bash
+# Required
+CHATTO_URL=https://chat.example.com
+CHATTO_LOGIN=hermes
+CHATTO_PASSWORD=your-password
+
+# Optional: restrict to specific rooms (comma-separated room IDs)
+# CHATTO_CHANNELS=REljMv5Pgolo6Y9,abc123def456
+
+# Optional: home channel for cron/notification delivery
+# CHATTO_HOME_CHANNEL=REljMv5Pgolo6Y9
+
+# Optional: restrict who can talk to the bot (comma-separated logins)
+# CHATTO_ALLOWED_USERS=alice,bob
+
+# Optional: allow any user to talk to the bot (default: false)
+# CHATTO_ALLOW_ALL_USERS=true
+
+# Optional: require @mention in rooms (default: true). DMs always respond.
+# CHATTO_REQUIRE_MENTION=true
+```
+
+Or configure via `~/.hermes/config.yaml`:
+
+```yaml
+gateway:
+  platforms:
+    chatto:
+      enabled: true
+      extra:
+        url: https://chat.example.com
+        channels:                  # room IDs to watch (empty = all joined)
+          - REljMv5Pgolo6Y9
+        home_channel: REljMv5Pgolo6Y9
+        require_mention: true      # only respond to @mentions in rooms
+        allowed_users: []          # empty = allow all (or set allow_all_users)
+        allow_all_users: true
+```
+
+:::note
+Environment variables override `config.yaml` values. Secrets (`CHATTO_PASSWORD`) should always go in `~/.hermes/.env`, not in `config.yaml`.
+:::
+
+### Environment Variables
+
+| Variable | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `CHATTO_URL` | Yes | — | Base URL of the Chatto server (e.g., `https://chat.example.com`) |
+| `CHATTO_LOGIN` | Yes | — | Chatto username (login) |
+| `CHATTO_PASSWORD` | Yes | — | Chatto password |
+| `CHATTO_CHANNELS` | No | All joined rooms | Comma-separated room IDs to watch |
+| `CHATTO_HOME_CHANNEL` | No | First watched room | Room ID for cron/notification delivery |
+| `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins allowed to talk to the agent |
+| `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
+| `CHATTO_REQUIRE_MENTION` | No | `true` | Only respond to `@mentions` in rooms. DMs always get a response. |
+
+### Start the Gateway
+
+Once configured, start the gateway:
+
+```bash
+hermes gateway
+```
+
+The bot should connect to your Chatto server within a few seconds. Send it a message — either a DM or `@mention` it in a room — to test.
+
+:::tip
+You can run `hermes gateway` in the background or as a systemd service for persistent operation. See the deployment docs for details.
+:::
+
+## Features
+
+### WebSocket Realtime Protocol
+
+The adapter maintains a persistent WebSocket connection to `/api/realtime` on the Chatto server using the binary protobuf realtime protocol (protocol v1). Inbound events — including room messages, mention notifications, and DM notifications — are decoded from protobuf frames in real time.
+
+Key protocol details:
+
+- **Client hello** — on connect, the adapter sends a `RealtimeClientHello` with protocol version 1 and a bearer token for authentication.
+- **Subscribe events** — after hello, the adapter subscribes to room timeline events for all watched rooms. A resume cursor is used to avoid replaying old messages after reconnection.
+- **Heartbeat** — the adapter sends ping frames every 30 seconds and expects pong responses. Missed heartbeats trigger a reconnect.
+- **Auto-reconnect** — if the WebSocket drops, the adapter reconnects with exponential backoff (1s → 30s max) and resumes from the last cursor.
+
+### ConnectRPC JSON API (Outbound)
+
+All outbound actions — sending messages, reactions, typing indicators, file uploads, etc. — use the Chatto ConnectRPC JSON API over HTTP POST. The adapter handles bearer token authentication and automatic re-login on 401 responses.
+
+### Markdown Support
+
+Hermes sends messages with full Markdown formatting. Chatto renders Markdown in its web UI, so code blocks, lists, bold/italic, links, and inline code all display correctly.
+
+### Thread / Reply Support
+
+When you reply to a message in Chatto (creating a thread), Hermes responds within that thread. Thread context stays isolated from the parent room — each thread has its own session namespace. The bot also auto-follows threads it participates in, so it continues to receive new messages in that thread.
+
+### Reactions
+
+Hermes uses emoji reactions for processing lifecycle notifications:
+
+- 👀 — added when the agent starts processing your message
+- ✅ — replaces 👀 when processing completes successfully
+- ❌ — replaces 👀 when processing fails
+
+The adapter converts Unicode emoji to Chatto shortcode names internally (e.g., `👀` → `eyes`, `✅` → `white_check_mark`). Reactions can also be added/removed programmatically by the agent.
+
+### Message Editing and Deletion
+
+The adapter supports editing existing messages (via `UpdateMessage`) and deleting them (via `DeleteMessage`). This is used for updating processing indicators and can be used by the agent to correct or retract messages.
+
+### Typing Indicators
+
+The adapter broadcasts persistent typing indicators to rooms while the agent is working. This uses the `UpdateTypingIndicator` RPC endpoint and runs as a background task per room, refreshing the indicator until the response is sent.
+
+### Message Batching and Splitting
+
+- **Splitting**: Messages longer than 10000 characters are automatically split into multiple sequential messages (split threshold: 9900 chars to leave room for separators).
+- **Batching**: Rapid successive outbound messages to the same room are merged into a single message with a 0.6-second batch delay, reducing noise in busy rooms.
+
+### Chunked File / Attachment Upload
+
+File attachments are uploaded via the Chatto chunked asset upload API:
+
+1. `CreateUpload` — initiates an upload session
+2. `UploadChunk` — uploads the file in 256 KB chunks
+3. `CompleteUpload` — finalizes the upload and attaches it to the message
+
+This supports files of any size, streamed in chunks to avoid memory issues.
+
+### Liveness Probe and Auto-Reconnect
+
+A background liveness probe checks the connection health every 60 seconds. After 3 consecutive failures, the adapter triggers a full reconnect — re-login, room discovery, and WebSocket reconnection with resume cursor support.
+
+### Read State Management
+
+After processing messages in a room, the adapter marks the room as read via `MarkRoomAsRead`. Thread read state is also managed via `MarkThreadAsRead`, so the bot's unread indicators stay accurate.
+
+### DM Initiation
+
+The adapter can proactively start direct messages with other Chatto users via the `StartDM` RPC endpoint. This is used for cron job delivery and notifications when no home channel is configured.
+
+### Room Creation
+
+The adapter can create new rooms via `CreateRoom`. This is available to the agent for organizing conversations or creating dedicated channels for tasks.
+
+### Notification Dismissal
+
+After processing mentions and DMs, the adapter dismisses the corresponding Chatto notifications via `DismissNotification` / `DismissAllNotifications`, keeping the bot's notification queue clean.
+
+### Member Directory
+
+The adapter maintains a cached member directory using `ListUsers`, `GetUser`, and `BatchGetUsers` endpoints. User lookups (for mention resolution, display names, etc.) are cached per user ID to reduce API calls.
+
+### Presence Broadcasting
+
+The adapter can broadcast presence status (online, away, do-not-disturb) via `UpdatePresence`. On startup, the bot sets itself to online. Status can be changed programmatically.
+
+| Presence Status | API Value |
+|-----------------|-----------|
+| Online | 1 |
+| Away | 2 |
+| Do Not Disturb | 3 |
+
+### Custom Status Messages
+
+The adapter supports setting and clearing custom status messages via `UpdateCustomStatus` / `DeleteCustomStatus`. This can be used to show "Processing…" or other contextual status indicators in the Chatto UI.
+
+## Usage Notes
+
+### Mention Detection
+
+In rooms with `CHATTO_REQUIRE_MENTION=true` (default), Hermes only responds when the message contains an `@mention` of the bot's login name. The mention is automatically stripped from the message before processing. In DMs, every message gets a response — no mention required.
+
+### Allowed Users
+
+By default, if neither `CHATTO_ALLOWED_USERS` nor `CHATTO_ALLOW_ALL_USERS` is set, the bot denies all users as a safety measure. Configure one of:
+
+- `CHATTO_ALLOWED_USERS=alice,bob` — only these Chatto logins can interact with the bot
+- `CHATTO_ALLOW_ALL_USERS=true` — any Chatto user can interact
+
+:::warning
+Setting `CHATTO_ALLOW_ALL_USERS=true` means any user on your Chatto server has full access to the agent's capabilities, including tool use and system access. Use this only on trusted, private Chatto instances.
+:::
+
+### Home Channel
+
+The home channel is where the bot sends proactive messages — cron job output, reminders, and notifications. Set it via:
+
+- `CHATTO_HOME_CHANNEL` env var (room ID)
+- `config.yaml` → `gateway.platforms.chatto.extra.home_channel`
+- `/sethome` slash command in any room where the bot is present
+
+If unset, the first watched room is used as the default home channel.
+
+### Thread Auto-Following
+
+When Hermes responds in a thread, it automatically follows that thread. This means it continues to receive all new messages in the thread, even if not explicitly `@mentioned` — similar to how a human user would follow a conversation they've joined.
+
+## Troubleshooting
+
+### Bot is not responding to messages
+
+**Cause**: The bot account is not a member of the room, or the user is not in `CHATTO_ALLOWED_USERS`.
+
+**Fix**: Verify the bot is a member of the room (the adapter auto-joins rooms listed in `CHATTO_CHANNELS`). Check that your Chatto login is in `CHATTO_ALLOWED_USERS`, or set `CHATTO_ALLOW_ALL_USERS=true`. Restart the gateway.
+
+### Connection refused / WebSocket fails to connect
+
+**Cause**: The Chatto server is unreachable, or the URL is incorrect.
+
+**Fix**: Verify `CHATTO_URL` points to your Chatto server (include `https://`, no trailing slash). Test connectivity:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}" https://chat.example.com/api/connect/chatto.api.v1.ViewerService/GetViewer
+```
+
+If you use a reverse proxy (nginx, Apache), ensure WebSocket upgrade headers are configured for `/api/realtime`:
+
+```nginx
+location /api/realtime {
+    proxy_pass http://chatto-backend;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+    proxy_read_timeout 600s;
+}
+```
+
+### Authentication failed
+
+**Cause**: The `CHATTO_LOGIN` or `CHATTO_PASSWORD` is incorrect.
+
+**Fix**: Verify credentials by testing the login endpoint directly:
+
+```bash
+curl -X POST https://chat.example.com/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"login":"hermes","password":"your-password"}'
+```
+
+If the response does not include a `token` field, the credentials are wrong. Reset the bot account password in Chatto and update `~/.hermes/.env`.
+
+### Protocol version mismatch
+
+**Cause**: The Chatto server is running an older version that doesn't support realtime protocol v1.
+
+**Fix**: The adapter uses protocol v1, supported by Chatto v0.4.19 and later. Upgrade your Chatto server:
+
+```bash
+# Check your Chatto server version
+curl -s https://chat.example.com/api/version | jq .
+```
+
+If you see a `RealtimeError` with a protocol-related message in the logs, upgrading the server is the fix.
+
+### WebSocket disconnects / reconnection loops
+
+**Cause**: Network instability, Chatto server restarts, or firewall/proxy issues with WebSocket connections.
+
+**Fix**: The adapter automatically reconnects with exponential backoff (1s → 30s max) and resumes from the last cursor. Check:
+
+1. Your server's WebSocket configuration — reverse proxies need upgrade headers (see above).
+2. No firewall is blocking WebSocket connections on your Chatto server.
+3. The Chatto server is running and healthy.
+
+Check gateway logs for details:
+
+```bash
+grep -i "chatto\|websocket\|realtime" ~/.hermes/logs/gateway.log | tail -30
+```
+
+### "No rooms to watch" on startup
+
+**Cause**: `CHATTO_CHANNELS` is not set and the bot account hasn't joined any rooms.
+
+**Fix**: Either join rooms with the bot account first, or set `CHATTO_CHANNELS` to specific room IDs. The adapter will auto-join rooms listed in `CHATTO_CHANNELS`.
+
+### 401 Unauthorized during operation
+
+**Cause**: The bearer token expired during a session.
+
+**Fix**: The adapter automatically re-logs in on 401 responses. If this fails repeatedly, check that the bot account hasn't been deactivated and the password hasn't changed. If the password was changed, update `CHATTO_PASSWORD` in `~/.hermes/.env` and restart the gateway.
+
+### Bot is offline
+
+**Cause**: The Hermes gateway isn't running, or it failed to connect.
+
+**Fix**: Check that `hermes gateway` is running. Look at the terminal output or gateway logs for error messages. Common issues: wrong URL, expired credentials, Chatto server unreachable. Run `hermes status` to check component health.
+
+## Security
+
+:::warning
+Always set `CHATTO_ALLOWED_USERS` to restrict who can interact with the bot. Without it (and without `CHATTO_ALLOW_ALL_USERS=true`), the gateway denies all users by default as a safety measure. Only add logins of people you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
+:::
+
+For more information on securing your Hermes Agent deployment, see the [Security Guide](../security.md).
+
+## Notes
+
+- **Self-hosted friendly**: Works with any self-hosted Chatto instance. No cloud account or subscription required.
+- **No extra dependencies**: The adapter uses `websockets` (bundled with Hermes) and a pure-stdlib protobuf codec. No `protobuf` library or other external packages needed.
+- **Pure stdlib protobuf**: The adapter implements just enough of the protobuf binary format to encode/decode Chatto realtime frames — no protoc compilation step, no generated code.
+- **Resume cursor support**: On reconnect, the adapter resumes from the last event cursor, so no messages are lost or replayed during transient disconnects.