Procházet zdrojové kódy

Add 9 more Chatto ConnectRPC plugin tools

- chatto_edit_message: edit a posted message's text (UpdateMessage)
- chatto_upload_file: local file -> chunked upload -> attachment message,
  gated by validate_media_delivery_path like the adapter's own sends
- chatto_add_reaction / chatto_remove_reaction: gemoji-shortcode reactions
- chatto_mark_room_as_read: room-wide read marker
- chatto_create_room: RoomService/CreateRoom in a room group
- chatto_list_room_members: member IDs hydrated to profiles
- chatto_get_user: directory lookup by user_id or exact login
- chatto_set_presence: online/away/do_not_disturb

All write tools obey the same opt-in and membership gates as
chatto_send_room; reads are membership-gated only. Schemas, handlers,
plugin.yaml, README, AGENTS.md and the bundled skill updated; 21 new
handler tests (418 passing in test_adapter.py). The 11 pre-existing
failures in TestInteractiveReactionRouting when both test files run
together are unchanged by this commit (verified against the baseline).
Liv Klumpp před 5 dny
rodič
revize
3f09bcec95
6 změnil soubory, kde provedl 1252 přidání a 33 odebrání
  1. 3 2
      AGENTS.md
  2. 11 2
      README.md
  3. 844 29
      adapter.py
  4. 9 0
      plugin.yaml
  5. 32 0
      skills/chatto-platform-plugin/SKILL.md
  6. 353 0
      test_adapter.py

+ 3 - 2
AGENTS.md

@@ -226,8 +226,9 @@ changes must keep them, or consciously renegotiate the docs.**
   tools obey the same lists: `chatto_send_room` and `chatto_send_ephemeral`
   refuse a channel on neither (a DM room is never gated), because a tool that
   posted there would break the read-only promise the same paragraph makes, and
-  `chatto_pin_message` and `chatto_delete_message` refuse it too — a pin and a
-  deletion change what the room shows everyone even though they post nothing.
+  `chatto_pin_message`, `chatto_delete_message`, `chatto_edit_message`,
+  `chatto_add_reaction` and `chatto_remove_reaction` refuse it too — they all
+  change what the room shows everyone even though they post nothing.
   The read tools are deliberately
   exempt from *that list* — `chatto_get_message`, `chatto_get_history` and
   `chatto_search_messages` post nothing and answer nothing, so the mention

+ 11 - 2
README.md

@@ -440,6 +440,15 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 | `chatto_list_pinned_messages` | Lists a room's pinned messages with their event ID, body, author and posting time, in pages of up to `limit` (default 20, offset-paged). |
 | `chatto_pin_message` | Pins or unpins one message by event ID (`pin: true` or `false`). |
 | `chatto_delete_message` | Deletes one message by event ID. Intended for the bot's own messages; the server decides who may delete what and its refusal is reported as an error. There is no un-delete. |
+| `chatto_edit_message` | Edits one message's text by event ID. The full new text replaces the old body; over the per-message limit it is refused, not truncated. |
+| `chatto_upload_file` | Uploads a local file (path on this machine) and posts it as a room attachment, with an optional caption. |
+| `chatto_add_reaction` | Adds an emoji reaction to a message, by gemoji shortcode name (or a known display emoji, which is translated). |
+| `chatto_remove_reaction` | Removes the bot's emoji reaction from a message, by gemoji shortcode name. |
+| `chatto_mark_room_as_read` | Marks a whole room as read for the bot (optionally up to an event ID); use `chatto_mark_thread_as_read` for a single thread. |
+| `chatto_create_room` | Creates a new channel in a room group and returns its ID. Needs the server-side room.create permission. |
+| `chatto_list_room_members` | Lists a room's members with login, display name and presence, offset-paged. |
+| `chatto_get_user` | Looks up one user by `user_id` or `user_login`: display name, login, presence, roles. |
+| `chatto_set_presence` | Sets the bot's presence to `online`, `away` or `do_not_disturb`; the running gateway keeps re-announcing it. |
 | `chatto_set_custom_status` | Sets the status shown next to the bot's name in Chatto clients: an `emoji` and `text`, optionally clearing itself after `expires_in_seconds`. |
 | `chatto_delete_custom_status` | Clears the bot's custom status. |
 | `chatto_fetch_link_preview` | Fetches the link preview Chatto's server would show for a URL: title, description, image URL, site name. Fields are empty when the site provides no metadata — that is not an error. |
@@ -449,10 +458,10 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 Five boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 
 - **A channel must be opted in.** `chatto_send_room` and `chatto_send_ephemeral` only post into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`. The read tools are exempt from that rule — `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`, `chatto_list_room_attachments`, `chatto_list_pinned_messages` and `chatto_list_room_groups` post nothing and answer nothing, so the mention lists have nothing to say about them. Membership is a separate rule, covered by the last bullet.
-- **Pinning and deleting are writes.** `chatto_pin_message` and `chatto_delete_message` change what the room shows everyone, so they obey the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the action and names both settings. A DM is never gated.
+- **Edits, pins, deletions and reactions are writes.** `chatto_edit_message`, `chatto_pin_message`, `chatto_delete_message`, `chatto_add_reaction` and `chatto_remove_reaction` change what the room shows everyone, so they obey the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the action and names both settings. A DM is never gated.
 - **The bot cannot open a DM.** Only a person can start a direct message on Chatto, so `chatto_send_dm` needs a recipient who has written to the bot at least once. To reach someone else, mention them in a channel message.
 - **A name is not an identity.** If several rooms share the name you passed, `chatto_send_room` returns their IDs and asks for `room_id` instead of picking one. Same rule as `/join #name`. The read tools resolve names by the same rule.
-- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room`, `chatto_send_ephemeral`, `chatto_pin_message` and `chatto_delete_message` refuse an unjoined room and say how to get in, because the server would reject the write with an error naming neither the room nor the fix. Reading is no free pass: Chatto requires membership for timeline and message reads, so `chatto_get_message` and `chatto_get_history` refuse an unjoined room the same way and name the same remedy. `chatto_search_messages` needs no check of its own — it searches only the rooms the caller can read, and the server re-checks every hit it returns.
+- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room`, `chatto_send_ephemeral`, `chatto_pin_message`, `chatto_delete_message`, `chatto_edit_message`, `chatto_add_reaction`, `chatto_remove_reaction` and `chatto_upload_file` refuse an unjoined room and say how to get in, because the server would reject the write with an error naming neither the room nor the fix. Reading is no free pass: Chatto requires membership for timeline and message reads, so `chatto_get_message` and `chatto_get_history` refuse an unjoined room the same way and name the same remedy. `chatto_search_messages` needs no check of its own — it searches only the rooms the caller can read, and the server re-checks every hit it returns.
 
 Credentials never come from the model: no tool takes a server URL or a token as an argument, so the configured bot token cannot be pointed at another host. Inside a channel, mention people in the message text (`@login, …`) — there is no separate mention parameter.
 

+ 844 - 29
adapter.py

@@ -58,6 +58,7 @@ from gateway.platforms.base import (
     cache_media_bytes,
     get_inbound_media_max_bytes,
     validate_inbound_media_size,
+    validate_media_delivery_path,
 )
 from gateway.session import SessionSource
 
@@ -95,6 +96,7 @@ try:
         stream_events,
     )
     from chattolib.types import (
+        DirectoryMember,
         Message,
         MessageAttachment,
         MessageSearchGroupBy,
@@ -5159,6 +5161,310 @@ CHATTO_DELETE_MESSAGE_SCHEMA = {
     },
 }
 
+CHATTO_EDIT_MESSAGE_SCHEMA = {
+    "name": "chatto_edit_message",
+    "description": (
+        "Edit the text of an already-posted message in a Chatto room, by its "
+        "event ID. Use it to correct the bot's own earlier messages — editing "
+        "another user's message is refused by the server. The full new text "
+        "replaces the old body; content over the per-message limit is refused "
+        "rather than truncated."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID the message lives in.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "event_id": {
+                "type": "string",
+                "description": "The event ID of the message to edit.",
+            },
+            "message": {
+                "type": "string",
+                "description": "The full new message text (markdown supported).",
+            },
+        },
+        "required": ["event_id", "message"],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_UPLOAD_FILE_SCHEMA = {
+    "name": "chatto_upload_file",
+    "description": (
+        "Upload a local file (image, video, audio or document) from this "
+        "machine and post it into a Chatto room as an attachment, with an "
+        "optional caption. Returns the asset ID and the message ID."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID of the destination.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "file_path": {
+                "type": "string",
+                "description": (
+                    "Absolute path of the local file to upload (e.g. a file "
+                    "this session produced earlier)."
+                ),
+            },
+            "caption": {
+                "type": "string",
+                "description": "Optional text posted alongside the attachment (markdown).",
+            },
+        },
+        "required": ["file_path"],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_ADD_REACTION_SCHEMA = {
+    "name": "chatto_add_reaction",
+    "description": (
+        "Add an emoji reaction to a message in a Chatto room. The emoji is "
+        "given as its gemoji shortcode name (e.g. 'white_check_mark', 'tada', "
+        "'rocket'); a Unicode emoji is translated when a shortcode is known "
+        "for it. Use reactions to acknowledge or rate messages."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID the message lives in.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "event_id": {
+                "type": "string",
+                "description": "The event ID of the message to react to.",
+            },
+            "emoji": {
+                "type": "string",
+                "description": (
+                    "The reaction as a gemoji shortcode (e.g. 'white_check_mark', "
+                    "'tada', 'rocket') or a known display emoji."
+                ),
+            },
+        },
+        "required": ["event_id", "emoji"],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_REMOVE_REACTION_SCHEMA = {
+    "name": "chatto_remove_reaction",
+    "description": (
+        "Remove the bot's emoji reaction from a message in a Chatto room. The "
+        "emoji is given as its gemoji shortcode name (see chatto_add_reaction)."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID the message lives in.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "event_id": {
+                "type": "string",
+                "description": "The event ID of the message the reaction is on.",
+            },
+            "emoji": {
+                "type": "string",
+                "description": "The reaction to remove, as a gemoji shortcode name.",
+            },
+        },
+        "required": ["event_id", "emoji"],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_MARK_ROOM_AS_READ_SCHEMA = {
+    "name": "chatto_mark_room_as_read",
+    "description": (
+        "Mark a Chatto room as read for the bot. Updates the server's read "
+        "marker so the room stops counting as unread. Useful after the bot "
+        "has fetched a room's context in bulk. Use chatto_mark_thread_as_read "
+        "for a single thread instead."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID to mark read.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "up_to_event_id": {
+                "type": "string",
+                "description": (
+                    "Optional: mark read only up to this event ID instead of "
+                    "the room's newest message."
+                ),
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_CREATE_ROOM_SCHEMA = {
+    "name": "chatto_create_room",
+    "description": (
+        "Create a new Chatto channel in a room group. Returns the new room's "
+        "ID. Room creation is a server-side privileged action — the bot "
+        "account needs the room.create permission, and the refusal is "
+        "reported when it does not."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "name": {
+                "type": "string",
+                "description": "The new room's display name.",
+            },
+            "group_id": {
+                "type": "string",
+                "description": (
+                    "The room group to create the room in "
+                    "(see chatto_list_room_groups)."
+                ),
+            },
+            "description": {
+                "type": "string",
+                "description": "Optional room description (markdown).",
+            },
+        },
+        "required": ["name", "group_id"],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_LIST_ROOM_MEMBERS_SCHEMA = {
+    "name": "chatto_list_room_members",
+    "description": (
+        "List a Chatto room's members with their login, display name and "
+        "presence, in pages. Use it to find who is in a room before mentioning "
+        "them or to resolve a user ID to a login."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID whose members to list.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "limit": {
+                "type": "integer",
+                "description": "Maximum members per page, 1-100 (default 50).",
+            },
+            "offset": {
+                "type": "integer",
+                "description": "Offset into the member list, for the next page.",
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_GET_USER_SCHEMA = {
+    "name": "chatto_get_user",
+    "description": (
+        "Look up a Chatto user in the member directory: display name, login, "
+        "presence and roles. Exactly one of ``user_id`` or ``user_login`` is "
+        "required."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "user_id": {
+                "type": "string",
+                "description": (
+                    "The Chatto user ID (e.g. 'Uxxxxxxxx'). Exactly one of "
+                    "`user_id` or `user_login` is required."
+                ),
+            },
+            "user_login": {
+                "type": "string",
+                "description": (
+                    "The Chatto login name (e.g. 'nickk'). Exactly one of "
+                    "`user_id` or `user_login` is required."
+                ),
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_SET_PRESENCE_SCHEMA = {
+    "name": "chatto_set_presence",
+    "description": (
+        "Set the bot's presence status in Chatto: 'online', 'away' or "
+        "'do_not_disturb'. The status expires server-side after about a "
+        "minute without refresh; while the gateway runs it is re-announced "
+        "automatically, so an explicit 'away' lasts until the next refresh "
+        "cycle unless the server supports preference persistence. 'offline' "
+        "cannot be set — presence lapses when the gateway disconnects."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "status": {
+                "type": "string",
+                "enum": ["online", "away", "do_not_disturb"],
+                "description": "The presence status to announce.",
+            },
+        },
+        "required": ["status"],
+        "additionalProperties": False,
+    },
+}
+
 CHATTO_SET_CUSTOM_STATUS_SCHEMA = {
     "name": "chatto_set_custom_status",
     "description": (
@@ -7387,39 +7693,503 @@ async def _handle_chatto_delete_message(args: dict) -> str:
         return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
 
-async def _delete_message(args: dict) -> str:
-    """Delete one message in a Chatto room. See _handle_chatto_delete_message."""
-    room_id = args.get("room_id") or ""
-    room_name = args.get("room_name") or ""
-    event_id = args.get("event_id") or ""
+async def _handle_chatto_edit_message(args: dict) -> str:
+    """Tool handler for chatto_edit_message. See _handle_chatto_delete_message."""
+    try:
+        return await _edit_message(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
-    if not room_id and not room_name:
-        raise _refuse("Exactly one of room_id or room_name is required")
-    if not event_id:
-        raise _refuse("event_id is required")
 
-    cfg = _chatto_config_for_tools()
-    async with _tool_client(cfg) as client:
-        resolved = await _resolve_room(client, room_id, room_name)
-        room = resolved.room
+async def _handle_chatto_add_reaction(args: dict) -> str:
+    """Tool handler for chatto_add_reaction. See _handle_chatto_delete_message."""
+    try:
+        return await _add_reaction(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
-        # Deleting changes what the room shows everyone, so it is a write:
-        # a room on neither mention list stays read-only, exactly as it does
-        # for chatto_send_room and chatto_pin_message.
-        if not _room_is_writable(cfg, room):
-            raise _read_only_room_refusal(room.name or room.id)
-        if not resolved.is_member:
-            raise _not_joined_refusal(room, "delete a message there")
 
-        deleted = await client.delete_message(room.id, event_id)
-        return json.dumps(
-            {
-                "success": True,
-                "deleted": bool(deleted),
-                "room_id": room.id,
-                "event_id": event_id,
-            }
-        )
+async def _handle_chatto_remove_reaction(args: dict) -> str:
+    """Tool handler for chatto_remove_reaction. See _handle_chatto_delete_message."""
+    try:
+        return await _remove_reaction(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_mark_room_as_read(args: dict) -> str:
+    """Tool handler for chatto_mark_room_as_read. See _handle_chatto_delete_message."""
+    try:
+        return await _mark_room_as_read(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_create_room(args: dict) -> str:
+    """Tool handler for chatto_create_room. See _handle_chatto_delete_message."""
+    try:
+        return await _create_room(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_list_room_members(args: dict) -> str:
+    """Tool handler for chatto_list_room_members. See _handle_chatto_delete_message."""
+    try:
+        return await _list_room_members(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_get_user(args: dict) -> str:
+    """Tool handler for chatto_get_user. See _handle_chatto_delete_message."""
+    try:
+        return await _get_user(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_set_presence(args: dict) -> str:
+    """Tool handler for chatto_set_presence. See _handle_chatto_delete_message."""
+    try:
+        return await _set_presence(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+    except ValueError as exc:
+        # chattolib refuses UNSPECIFIED/OFFLINE outright with a ValueError.
+        return json.dumps({"success": False, "error": str(exc)})
+
+
+async def _handle_chatto_upload_file(args: dict) -> str:
+    """Tool handler for chatto_upload_file. See _handle_chatto_delete_message."""
+    try:
+        return await _upload_file(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _delete_message(args: dict) -> str:
+    """Delete one message in a Chatto room. See _handle_chatto_delete_message."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not event_id:
+        raise _refuse("event_id is required")
+
+    cfg = _chatto_config_for_tools()
+    async with _tool_client(cfg) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        # Deleting changes what the room shows everyone, so it is a write:
+        # a room on neither mention list stays read-only, exactly as it does
+        # for chatto_send_room and chatto_pin_message.
+        if not _room_is_writable(cfg, room):
+            raise _read_only_room_refusal(room.name or room.id)
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "delete a message there")
+
+        deleted = await client.delete_message(room.id, event_id)
+        return json.dumps(
+            {
+                "success": True,
+                "deleted": bool(deleted),
+                "room_id": room.id,
+                "event_id": event_id,
+            }
+        )
+
+
+async def _edit_message(args: dict) -> str:
+    """Edit one message's text. See _handle_chatto_edit_message."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+    message = args.get("message") or ""
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not event_id:
+        raise _refuse("event_id is required")
+    if not message:
+        raise _refuse("message (the full new text) is required")
+
+    cfg = _chatto_config_for_tools()
+    async with _tool_client(cfg) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        # An edit is a write like any other: the read-only promise holds.
+        if not _room_is_writable(cfg, room):
+            raise _read_only_room_refusal(room.name or room.id)
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "edit a message there")
+
+        formatted = _normalise_outbound_text(message)
+        if len(formatted) > ChattoConstants.MAX_MESSAGE_LENGTH:
+            raise _refuse(
+                f"message exceeds the {ChattoConstants.MAX_MESSAGE_LENGTH}-char "
+                f"per-message limit ({len(formatted)}); it was not sent"
+            )
+        await client.update_message(room.id, event_id, body=formatted)
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "event_id": event_id,
+            }
+        )
+
+
+def _emoji_shortcode(emoji: str) -> str:
+    """The shortcode name a reaction must be sent as (see _shortcode_for_reaction_emoji)."""
+    return _shortcode_for_reaction_emoji(emoji)
+
+
+def _reaction_result(action: str, room: Room, event_id: str, emoji: str, done: bool) -> str:
+    """One reaction tool's result shape, shared by add and remove."""
+    result: dict[str, Any] = {
+        "success": True,
+        "action": action,
+        "room_id": room.id,
+        "event_id": event_id,
+        "emoji": emoji,
+    }
+    if action == "add":
+        result["added"] = bool(done)
+    else:
+        result["removed"] = bool(done)
+    return json.dumps(result)
+
+
+async def _add_reaction(args: dict) -> str:
+    """Add an emoji reaction. See _handle_chatto_add_reaction."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+    emoji = (args.get("emoji") or "").strip()
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not event_id:
+        raise _refuse("event_id is required")
+    if not emoji:
+        raise _refuse("emoji is required")
+
+    cfg = _chatto_config_for_tools()
+    async with _tool_client(cfg) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        # A reaction is visible to everyone — a write like a pin.
+        if not _room_is_writable(cfg, room):
+            raise _read_only_room_refusal(room.name or room.id)
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "react there")
+
+        shortcode = _emoji_shortcode(emoji)
+        added = await client.add_reaction(room.id, event_id, shortcode)
+        return _reaction_result("add", room, event_id, shortcode, added)
+
+
+async def _remove_reaction(args: dict) -> str:
+    """Remove an emoji reaction. See _handle_chatto_remove_reaction."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+    emoji = (args.get("emoji") or "").strip()
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not event_id:
+        raise _refuse("event_id is required")
+    if not emoji:
+        raise _refuse("emoji is required")
+
+    cfg = _chatto_config_for_tools()
+    async with _tool_client(cfg) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        if not _room_is_writable(cfg, room):
+            raise _read_only_room_refusal(room.name or room.id)
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "react there")
+
+        shortcode = _emoji_shortcode(emoji)
+        removed = await client.remove_reaction(room.id, event_id, shortcode)
+        return _reaction_result("remove", room, event_id, shortcode, removed)
+
+
+async def _mark_room_as_read(args: dict) -> str:
+    """Mark a room read for the bot. See _handle_chatto_mark_room_as_read."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    up_to_event_id = args.get("up_to_event_id") or ""
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        if not resolved.is_member:
+            # A read marker is private state, but the server scopes it to
+            # membership — same remedy as every other room-scoped call.
+            raise _not_joined_refusal(room, "mark it read")
+
+        last_read, previous = await client.mark_room_as_read(room.id, up_to_event_id)
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "last_read_at": _iso_or_none(last_read),
+                "previous_last_read_at": _iso_or_none(previous),
+            }
+        )
+
+
+async def _create_room(args: dict) -> str:
+    """Create a channel in a room group. See _handle_chatto_create_room."""
+    name = (args.get("name") or "").strip()
+    group_id = (args.get("group_id") or "").strip()
+    description = args.get("description") or ""
+
+    if not name:
+        raise _refuse("name is required")
+    if not group_id:
+        raise _refuse(
+            "group_id is required — chatto_list_room_groups lists the groups"
+        )
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        room = await client.create_room(
+            name=name, group_id=group_id, description=description
+        )
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "name": room.name,
+                "kind": room.kind.value,
+                "hint": (
+                    "The bot is not a member of the new room yet. Invite it in "
+                    "Chatto, or DM it `/join "
+                    f"{room.id}`"
+                ),
+            }
+        )
+
+
+def _member_out(member: DirectoryMember) -> dict[str, Any]:
+    """One directory member as the tools' JSON shape."""
+    user = member.user
+    out: dict[str, Any] = {
+        "roles": list(member.roles or []),
+    }
+    if user is not None:
+        out.update(
+            {
+                "user_id": user.id,
+                "login": user.login,
+                "display_name": user.display_name,
+                "presence": _PRESENCE_LABELS.get(user.presence_status, ""),
+                "deleted": user.deleted,
+            }
+        )
+    return out
+
+
+async def _list_room_members(args: dict) -> str:
+    """List a room's members. See _handle_chatto_list_room_members."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    limit = _int_arg(args, "limit", 50, low=1, high=100)
+    offset = _int_arg(args, "offset", 0, low=0, high=100_000)
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        # The server enforces room membership for the member directory too,
+        # so an unjoined room is refused with the usual remedy.
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "list its members")
+
+        member_ids, page = await client.list_room_members(
+            room.id, limit=limit, offset=offset
+        )
+        members = await client.batch_get_room_members(room.id, member_ids)
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "count": len(members),
+                "offset": offset,
+                "has_more": page.has_more,
+                "total_count": page.total_count,
+                "members": [_member_out(member) for member in members],
+            }
+        )
+
+
+async def _get_user(args: dict) -> str:
+    """Look up one user. See _handle_chatto_get_user."""
+    user_id = (args.get("user_id") or "").strip()
+    user_login = (args.get("user_login") or "").strip()
+
+    if bool(user_id) == bool(user_login):
+        raise _refuse("Exactly one of user_id or user_login is required")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        if user_id:
+            matches = await client.batch_get_users([user_id])
+            member = matches[0] if matches else None
+        else:
+            # A login needs the server-wide directory, which batch_get_users
+            # cannot search; list_users' search does.
+            members, _page = await client.list_users(search=user_login, limit=25)
+            member = next(
+                (
+                    m
+                    for m in members
+                    if m.user is not None
+                    and m.user.login.casefold() == user_login.casefold()
+                ),
+                None,
+            )
+        if member is None or member.user is None:
+            raise _refuse(
+                f"No user found for "
+                f"{'user_id ' + user_id if user_id else 'login ' + user_login}. "
+                "The directory search may be scoped to members of rooms the "
+                "bot can see."
+            )
+        return json.dumps({"success": True, **_member_out(member)})
+
+
+_PRESENCE_BY_NAME = {
+    "online": PresenceStatus.ONLINE,
+    "away": PresenceStatus.AWAY,
+    "do_not_disturb": PresenceStatus.DO_NOT_DISTURB,
+}
+
+
+async def _set_presence(args: dict) -> str:
+    """Set the bot's presence status. See _handle_chatto_set_presence."""
+    status_name = (args.get("status") or "").strip()
+    status = _PRESENCE_BY_NAME.get(status_name)
+    if status is None:
+        raise _refuse(
+            "status must be one of: online, away, do_not_disturb"
+        )
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        set_status = await client.set_presence(status)
+        return json.dumps(
+            {
+                "success": True,
+                "status": _PRESENCE_LABELS.get(set_status, set_status.value),
+                "note": (
+                    "Presence expires server-side without refresh; the running "
+                    "gateway re-announces it periodically."
+                ),
+            }
+        )
+
+
+async def _upload_file(args: dict) -> str:
+    """Upload a local file and post it. See _handle_chatto_upload_file."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    file_path = (args.get("file_path") or "").strip()
+    caption = args.get("caption") or ""
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not file_path:
+        raise _refuse("file_path is required")
+
+    # Same path gate as the adapter's own sends: a model-named path must not
+    # become a channel for reading host secrets into the chat.
+    safe_path = validate_media_delivery_path(file_path)
+    if not safe_path:
+        raise _refuse(
+            f"file_path '{file_path}' is not a deliverable file on this host "
+            "(missing, or outside the paths allowed for media delivery)"
+        )
+
+    cfg = _chatto_config_for_tools()
+    async with _tool_client(cfg) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        if not _room_is_writable(cfg, room):
+            raise _read_only_room_refusal(room.name or room.id)
+        if not resolved.is_member:
+            raise _not_joined_refusal(room, "post an attachment there")
+
+        asset = await client.upload_attachment(
+            room.id, safe_path, filename=Path(safe_path).name
+        )
+        message = await client.post_message(
+            room.id,
+            _normalise_outbound_text(caption),
+            attachment_asset_ids=[asset.id],
+        )
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "message_id": message.id,
+                "asset_id": asset.id,
+                "filename": asset.filename,
+                "size": asset.size,
+            }
+        )
 
 
 def _register_bundled_skills(ctx) -> None:
@@ -7490,6 +8260,51 @@ _PLUGIN_TOOLS = (
         CHATTO_DELETE_MESSAGE_SCHEMA,
         _handle_chatto_delete_message,
     ),
+    (
+        "chatto_edit_message",
+        CHATTO_EDIT_MESSAGE_SCHEMA,
+        _handle_chatto_edit_message,
+    ),
+    (
+        "chatto_upload_file",
+        CHATTO_UPLOAD_FILE_SCHEMA,
+        _handle_chatto_upload_file,
+    ),
+    (
+        "chatto_add_reaction",
+        CHATTO_ADD_REACTION_SCHEMA,
+        _handle_chatto_add_reaction,
+    ),
+    (
+        "chatto_remove_reaction",
+        CHATTO_REMOVE_REACTION_SCHEMA,
+        _handle_chatto_remove_reaction,
+    ),
+    (
+        "chatto_mark_room_as_read",
+        CHATTO_MARK_ROOM_AS_READ_SCHEMA,
+        _handle_chatto_mark_room_as_read,
+    ),
+    (
+        "chatto_create_room",
+        CHATTO_CREATE_ROOM_SCHEMA,
+        _handle_chatto_create_room,
+    ),
+    (
+        "chatto_list_room_members",
+        CHATTO_LIST_ROOM_MEMBERS_SCHEMA,
+        _handle_chatto_list_room_members,
+    ),
+    (
+        "chatto_get_user",
+        CHATTO_GET_USER_SCHEMA,
+        _handle_chatto_get_user,
+    ),
+    (
+        "chatto_set_presence",
+        CHATTO_SET_PRESENCE_SCHEMA,
+        _handle_chatto_set_presence,
+    ),
     (
         "chatto_set_custom_status",
         CHATTO_SET_CUSTOM_STATUS_SCHEMA,

+ 9 - 0
plugin.yaml

@@ -22,6 +22,15 @@ provides_tools:
   - chatto_list_pinned_messages
   - chatto_pin_message
   - chatto_delete_message
+  - chatto_edit_message
+  - chatto_upload_file
+  - chatto_add_reaction
+  - chatto_remove_reaction
+  - chatto_mark_room_as_read
+  - chatto_create_room
+  - chatto_list_room_members
+  - chatto_get_user
+  - chatto_set_presence
   - chatto_set_custom_status
   - chatto_delete_custom_status
   - chatto_fetch_link_preview

+ 32 - 0
skills/chatto-platform-plugin/SKILL.md

@@ -79,6 +79,38 @@ Looks up the user by login or ID and posts into the DM room.
 
 Deletes a message by event ID (MessageService/DeleteMessage via `client.delete_message`). Intended for the bot's own messages; the server decides who may delete what and a refusal comes back as a `Chatto API error` — no fake success. It is a write: same opt-in and membership gates as `chatto_send_room`/`chatto_pin_message` (read-only room and unjoined-room refusals). There is no un-delete.
 
+### chatto_edit_message(room_id|room_name, event_id, message) — edit one message's text
+
+Full replacement of the body via MessageService/UpdateMessage. Over `MAX_MESSAGE_LENGTH` it is refused, not truncated. Same write gates as delete.
+
+### chatto_upload_file(room_id|room_name, file_path, caption?) — post a local file as attachment
+
+Uploads via the chunked AssetUploadService (`client.upload_attachment`) and posts one message carrying the asset. `file_path` runs through `validate_media_delivery_path` — the same host-path gate as the adapter's own sends, so a model-named path cannot read `/etc` or `~/.ssh` into the chat. Same write gates.
+
+### chatto_add_reaction / chatto_remove_reaction(room_id|room_name, event_id, emoji) — react to a message
+
+The wire takes gemoji shortcodes only; display emojis are translated by `_shortcode_for_reaction_emoji` (the same table the interactive prompts use), an unknown emoji is sent as-is and refused by the server. Both are writes: same opt-in and membership gates.
+
+### chatto_mark_room_as_read(room_id|room_name, up_to_event_id?) — mark a room read
+
+Room-wide read marker via ReadStateService/MarkRoomAsRead; `chatto_mark_thread_as_read` covers a single thread. Membership-gated (the server scopes the marker), not opt-in gated — it changes no room content.
+
+### chatto_create_room(name, group_id, description?) — create a channel
+
+RoomService/CreateRoom in a room group (see `chatto_list_room_groups`). Needs the server-side room.create permission; the server's refusal is reported. The bot is not a member of the new room — the result's `hint` says how to join it.
+
+### chatto_list_room_members(room_id|room_name, limit?, offset?) — list a room's members
+
+Member IDs from ListMembers, hydrated to profiles via `batch_get_room_members` — logins, display names, presence. Membership-gated.
+
+### chatto_get_user(user_id|user_login) — look up one user
+
+By ID via `batch_get_users`; by login via `list_users(search=…)` plus an exact case-insensitive login match — the search is a partial match, so `nick` must not answer as `nickk2`. The server-wide directory can be permission-scoped; a miss is a refusal, not a guess.
+
+### chatto_set_presence(status) — set the bot's presence
+
+`online` / `away` / `do_not_disturb` via `client.set_presence` (the transient report the adapter's own refresh loop uses). `offline` cannot be set — chattolib refuses it; presence lapses on disconnect.
+
 ## Cron delivery patterns
 
 - **Split content destination from report destination.** Content goes out via `chatto_send_room`/`chatto_send_dm` called BY the job prompt; the cron `deliver` field routes only the final confirmation/report (e.g. `deliver: "chatto-platform:<room_id>"` — resolve the ID via `chatto_list_rooms`). A `deliver` target without a `:thread_id` segment lands in the channel's main chat.

+ 353 - 0
test_adapter.py

@@ -114,6 +114,15 @@ from adapter import (
     _handle_chatto_list_room_groups,
     _handle_chatto_list_rooms,
     _handle_chatto_delete_message,
+    _handle_chatto_edit_message,
+    _handle_chatto_add_reaction,
+    _handle_chatto_remove_reaction,
+    _handle_chatto_mark_room_as_read,
+    _handle_chatto_create_room,
+    _handle_chatto_list_room_members,
+    _handle_chatto_get_user,
+    _handle_chatto_set_presence,
+    _handle_chatto_upload_file,
     _handle_chatto_mark_thread_as_read,
     _handle_chatto_pin_message,
     _handle_chatto_search_messages,
@@ -5508,6 +5517,15 @@ class TestPluginToolSchemas:
             "chatto_list_pinned_messages",
             "chatto_pin_message",
             "chatto_delete_message",
+            "chatto_edit_message",
+            "chatto_upload_file",
+            "chatto_add_reaction",
+            "chatto_remove_reaction",
+            "chatto_mark_room_as_read",
+            "chatto_create_room",
+            "chatto_list_room_members",
+            "chatto_get_user",
+            "chatto_set_presence",
             "chatto_set_custom_status",
             "chatto_delete_custom_status",
             "chatto_fetch_link_preview",
@@ -5551,6 +5569,31 @@ class TestPluginToolHandlers:
         client.pin_message = AsyncMock()
         client.unpin_message = AsyncMock(return_value=True)
         client.delete_message = AsyncMock(return_value=True)
+        client.update_message = AsyncMock(
+            side_effect=lambda *a, **kw: _make_message(
+                message_id=kw.get("event_id") or (a[1] if len(a) > 1 else "msg-edit")
+            )
+        )
+        client.add_reaction = AsyncMock(return_value=True)
+        client.remove_reaction = AsyncMock(return_value=True)
+        client.mark_room_as_read = AsyncMock(return_value=(None, None))
+        client.create_room = AsyncMock(
+            side_effect=lambda *a, **kw: _make_room(
+                "room-new", kw.get("name") or (a[0] if a else "new"), RoomKind.CHANNEL
+            )
+        )
+        client.list_room_members = AsyncMock(return_value=([], Page()))
+        client.batch_get_users = AsyncMock(return_value=[])
+        client.list_users = AsyncMock(return_value=([], Page()))
+        client.set_presence = AsyncMock(return_value=PresenceStatus.ONLINE)
+        client.upload_attachment = AsyncMock(
+            return_value=Asset(
+                id="asset-1",
+                filename="report.pdf",
+                content_type="application/pdf",
+                size=1234,
+            )
+        )
         client.set_custom_status = AsyncMock(return_value={})
         client.delete_custom_status = AsyncMock(return_value={})
         client.fetch_link_preview = AsyncMock(return_value=(None, ""))
@@ -7294,6 +7337,316 @@ class TestPluginToolHandlers:
         assert result["success"] is False
         assert "Chatto API error" in result["error"]
 
+    # -- chatto_edit_message --
+
+    async def test_edit_message_edits_in_an_opted_in_channel(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_edit_message,
+            {"room_id": "room-1", "event_id": "msg-1", "message": "corrected"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is True
+        client.update_message.assert_awaited_once_with(
+            "room-1", "msg-1", body="corrected"
+        )
+
+    async def test_edit_message_refuses_a_read_only_room(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_edit_message,
+            {"room_id": "room-1", "event_id": "msg-1", "message": "x"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.update_message.assert_not_awaited()
+
+    async def test_edit_message_refuses_an_overlong_body(self):
+        """Over the per-message limit the edit is refused, not truncated — a
+        silently shortened 'correction' would be worse than the typo."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_edit_message,
+            {
+                "room_id": "room-1",
+                "event_id": "msg-1",
+                "message": "x" * (_MAX_MESSAGE_LENGTH + 1),
+            },
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is False
+        assert "limit" in result["error"]
+        client.update_message.assert_not_awaited()
+
+    async def test_edit_message_requires_a_body(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_edit_message,
+            {"room_id": "room-1", "event_id": "msg-1", "message": ""},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is False
+        assert "message" in result["error"]
+        client.update_message.assert_not_awaited()
+
+    # -- chatto_add_reaction / chatto_remove_reaction --
+
+    async def test_add_reaction_translates_a_unicode_emoji(self):
+        """The wire takes gemoji shortcodes only; a display emoji is translated
+        by the same table the interactive prompts use."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_add_reaction,
+            {"room_id": "room-1", "event_id": "msg-1", "emoji": "✅"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is True
+        assert result["added"] is True
+        client.add_reaction.assert_awaited_once_with(
+            "room-1", "msg-1", "white_check_mark"
+        )
+
+    async def test_add_reaction_refuses_a_read_only_room(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_add_reaction,
+            {"room_id": "room-1", "event_id": "msg-1", "emoji": "tada"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.add_reaction.assert_not_awaited()
+
+    async def test_remove_reaction_removes_by_shortcode(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_remove_reaction,
+            {"room_id": "room-1", "event_id": "msg-1", "emoji": "tada"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is True
+        client.remove_reaction.assert_awaited_once_with("room-1", "msg-1", "tada")
+
+    # -- chatto_mark_room_as_read --
+
+    async def test_mark_room_as_read_returns_the_previous_marker(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.mark_room_as_read = AsyncMock(return_value=(None, None))
+        result, _, _ = await self._run(
+            _handle_chatto_mark_room_as_read, {"room_id": "room-1"}, client=client
+        )
+        assert result["success"] is True
+        client.mark_room_as_read.assert_awaited_once_with("room-1", "")
+
+    async def test_mark_room_as_read_refuses_an_unjoined_room(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_mark_room_as_read, {"room_id": "room-1"}, client=client
+        )
+        assert result["success"] is False
+        assert "cannot mark it read" in result["error"]
+        client.mark_room_as_read.assert_not_awaited()
+
+    # -- chatto_create_room --
+
+    async def test_create_room_creates_and_names_the_remedy(self):
+        room = _make_room("room-new", "Incident", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.create_room = AsyncMock(return_value=room)
+        result, _, _ = await self._run(
+            _handle_chatto_create_room,
+            {"name": "Incident", "group_id": "group-1"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["room_id"] == "room-new"
+        client.create_room.assert_awaited_once_with(
+            name="Incident", group_id="group-1", description=""
+        )
+        assert "/join room-new" in result["hint"]
+
+    async def test_create_room_requires_a_group(self):
+        client = self._stub_client()
+        result, _, _ = await self._run(
+            _handle_chatto_create_room, {"name": "Incident"}, client=client
+        )
+        assert result["success"] is False
+        assert "group_id" in result["error"]
+        client.create_room.assert_not_awaited()
+
+    # -- chatto_list_room_members --
+
+    async def test_list_room_members_hydrates_profiles(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.list_room_members = AsyncMock(
+            return_value=(["user-1"], Page(total_count=1, has_more=False))
+        )
+        client.batch_get_room_members = AsyncMock(
+            return_value=[DirectoryMember(user=_make_user("user-1", "alice"))]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_list_room_members, {"room_id": "room-1"}, client=client
+        )
+        assert result["success"] is True
+        assert result["members"][0]["login"] == "alice"
+
+    async def test_list_room_members_refuses_an_unjoined_room(self):
+        """Membership gates reads too (the server enforces it) — same remedy
+        as chatto_get_history."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(
+            return_value=_make_room_state(room, is_member=False)
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_list_room_members, {"room_id": "room-1"}, client=client
+        )
+        assert result["success"] is False
+        client.list_room_members.assert_not_awaited()
+
+    # -- chatto_get_user --
+
+    async def test_get_user_by_id_uses_the_batch_lookup(self):
+        client = self._stub_client()
+        client.batch_get_users = AsyncMock(
+            return_value=[DirectoryMember(user=_make_user("user-1", "alice"))]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_user, {"user_id": "user-1"}, client=client
+        )
+        assert result["success"] is True
+        assert result["login"] == "alice"
+        client.batch_get_users.assert_awaited_once_with(["user-1"])
+
+    async def test_get_user_by_login_matches_the_exact_login(self):
+        """list_users' search is a prefix/partial search; an exact case-insensitive
+        match on the login keeps 'nick' from answering as 'nickk2'."""
+        client = self._stub_client()
+        client.list_users = AsyncMock(
+            return_value=(
+                [
+                    DirectoryMember(user=_make_user("user-2", "nickk2")),
+                    DirectoryMember(user=_make_user("user-1", "NickK")),
+                ],
+                Page(total_count=2, has_more=False),
+            )
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_user, {"user_login": "nickk"}, client=client
+        )
+        assert result["success"] is True
+        assert result["user_id"] == "user-1"
+
+    async def test_get_user_refuses_both_or_neither(self):
+        client = self._stub_client()
+        for args in ({"user_id": "u", "user_login": "n"}, {}):
+            result, _, _ = await self._run(
+                _handle_chatto_get_user, args, client=client
+            )
+            assert result["success"] is False
+            assert "user_id" in result["error"]
+        client.batch_get_users.assert_not_awaited()
+        client.list_users.assert_not_awaited()
+
+    # -- chatto_set_presence --
+
+    async def test_set_presence_maps_the_status(self):
+        client = self._stub_client()
+        client.set_presence = AsyncMock(return_value=PresenceStatus.AWAY)
+        result, _, _ = await self._run(
+            _handle_chatto_set_presence, {"status": "away"}, client=client
+        )
+        assert result["success"] is True
+        client.set_presence.assert_awaited_once_with(PresenceStatus.AWAY)
+
+    async def test_set_presence_refuses_an_unknown_status(self):
+        client = self._stub_client()
+        result, _, _ = await self._run(
+            _handle_chatto_set_presence, {"status": "invisible"}, client=client
+        )
+        assert result["success"] is False
+        assert "online, away, do_not_disturb" in result["error"]
+        client.set_presence.assert_not_awaited()
+
+    # -- chatto_upload_file --
+
+    async def test_upload_file_uploads_and_posts_in_an_opted_in_channel(self, tmp_path):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        target = tmp_path / "report.pdf"
+        target.write_bytes(b"%PDF-1.4 test")
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_upload_file,
+            {"room_id": "room-1", "file_path": str(target)},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is True
+        assert result["asset_id"] == "asset-1"
+        client.upload_attachment.assert_awaited_once()
+        client.post_message.assert_awaited_once()
+
+    async def test_upload_file_refuses_an_unsafe_path(self):
+        """A model-named path must not become a channel for reading host
+        secrets into the chat: the same media-delivery gate as the adapter's
+        own sends runs here."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_upload_file,
+            {"room_id": "room-1", "file_path": "/etc/shadow"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is False
+        assert "deliverable" in result["error"]
+        client.upload_attachment.assert_not_awaited()
+
+    async def test_upload_file_refuses_a_read_only_room(self, tmp_path):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        target = tmp_path / "report.pdf"
+        target.write_bytes(b"%PDF-1.4 test")
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_upload_file,
+            {"room_id": "room-1", "file_path": str(target)},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.upload_attachment.assert_not_awaited()
+
     # -- chatto_send_ephemeral --
 
     async def test_send_ephemeral_posts_then_schedules_the_ttl(self):