Jelajahi Sumber

Add chatto_delete_message plugin tool

Registers chatto_delete_message(room_id|room_name, event_id) as a
regular plugin tool so message deletion no longer needs raw
Connect-API calls. Same gates as the other write tools: read-only
room and unjoined-room refusals; the server's own answer about who
may delete what is reported, not faked.
Liv Klumpp 5 hari lalu
induk
melakukan
a3ac2dbbb6
6 mengubah file dengan 198 tambahan dan 4 penghapusan
  1. 3 2
      AGENTS.md
  2. 3 2
      README.md
  3. 92 0
      adapter.py
  4. 1 0
      plugin.yaml
  5. 4 0
      skills/chatto-platform-plugin/SKILL.md
  6. 95 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` refuses it too — a pin changes what the room shows
-  everyone even though it posts nothing. The read tools are deliberately
+  `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.
+  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
   lists have nothing to say about them — but not from membership:

+ 3 - 2
README.md

@@ -439,6 +439,7 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 | `chatto_list_room_attachments` | Lists the file attachments of a room with their ID, filename, MIME type and size, in pages of up to `limit` (default 40, offset-paged). `has_more` and `total_count` describe the rest of the list. |
 | `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_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. |
@@ -448,10 +449,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 is a write.** `chatto_pin_message` changes what the room shows everyone, so it obeys the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the pin and names both settings. A DM is never gated.
+- **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.
 - **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` and `chatto_pin_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` 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.
 
 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.
 

+ 92 - 0
adapter.py

@@ -5125,6 +5125,40 @@ CHATTO_PIN_MESSAGE_SCHEMA = {
     },
 }
 
+CHATTO_DELETE_MESSAGE_SCHEMA = {
+    "name": "chatto_delete_message",
+    "description": (
+        "Delete one message in a Chatto room by event ID. Intended for "
+        "cleaning up the bot's own messages (e.g. a summary posted into the "
+        "wrong room); the server enforces who may delete what — a bot "
+        "account can usually delete only its own messages, and the server's "
+        "refusal is reported as an error. Deleted messages are gone from the "
+        "room for everyone; there is no un-delete."
+    ),
+    "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 message event ID to delete.",
+            },
+        },
+        "required": ["event_id"],
+        "additionalProperties": False,
+    },
+}
+
 CHATTO_SET_CUSTOM_STATUS_SCHEMA = {
     "name": "chatto_set_custom_status",
     "description": (
@@ -7335,6 +7369,59 @@ async def _handle_chatto_pin_message(args: dict) -> str:
         return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
 
+async def _handle_chatto_delete_message(args: dict) -> str:
+    """Tool handler for chatto_delete_message.
+
+    Returns a JSON string: Hermes' tool contract accepts no other type, and a
+    dict (or an unflagged coroutine) fails at dispatch with "unsupported result
+    type". Every early exit arrives here as a _ToolRefusal and leaves as that
+    refusal's own JSON, so no path escapes as an unhandled error.
+    """
+    try:
+        return await _delete_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}"})
+
+
+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,
+            }
+        )
+
+
 def _register_bundled_skills(ctx) -> None:
     """Register the skills shipped in ``skills/``, each with its own description.
 
@@ -7398,6 +7485,11 @@ _PLUGIN_TOOLS = (
         CHATTO_PIN_MESSAGE_SCHEMA,
         _handle_chatto_pin_message,
     ),
+    (
+        "chatto_delete_message",
+        CHATTO_DELETE_MESSAGE_SCHEMA,
+        _handle_chatto_delete_message,
+    ),
     (
         "chatto_set_custom_status",
         CHATTO_SET_CUSTOM_STATUS_SCHEMA,

+ 1 - 0
plugin.yaml

@@ -21,6 +21,7 @@ provides_tools:
   - chatto_list_room_attachments
   - chatto_list_pinned_messages
   - chatto_pin_message
+  - chatto_delete_message
   - chatto_set_custom_status
   - chatto_delete_custom_status
   - chatto_fetch_link_preview

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

@@ -75,6 +75,10 @@ Looks up the user by login or ID and posts into the DM room.
 
 **Precondition: the recipient must have DMed the bot once.** Bots can NEVER start a DM — by design (Chatto dev-docs, guides/operations/permissions: "Only people can start a DM... A bot cannot start a DM, even with message.post."). No permission or role change lifts this. The handler therefore never calls `start_dm`: with no existing DM room it refuses and says so (`_find_dm_room`). A login is resolved among the members of rooms the bot shares with that person (`_find_user_by_login`), because reading the server-wide user directory needs admin rights.
 
+### chatto_delete_message(room_id|room_name, event_id) — delete one message
+
+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.
+
 ## 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.

+ 95 - 0
test_adapter.py

@@ -113,6 +113,7 @@ from adapter import (
     _handle_chatto_list_room_attachments,
     _handle_chatto_list_room_groups,
     _handle_chatto_list_rooms,
+    _handle_chatto_delete_message,
     _handle_chatto_mark_thread_as_read,
     _handle_chatto_pin_message,
     _handle_chatto_search_messages,
@@ -5506,6 +5507,7 @@ class TestPluginToolSchemas:
             "chatto_list_room_attachments",
             "chatto_list_pinned_messages",
             "chatto_pin_message",
+            "chatto_delete_message",
             "chatto_set_custom_status",
             "chatto_delete_custom_status",
             "chatto_fetch_link_preview",
@@ -5548,6 +5550,7 @@ class TestPluginToolHandlers:
         client.list_pinned_messages = AsyncMock(return_value=PinnedMessagesPage())
         client.pin_message = AsyncMock()
         client.unpin_message = AsyncMock(return_value=True)
+        client.delete_message = AsyncMock(return_value=True)
         client.set_custom_status = AsyncMock(return_value={})
         client.delete_custom_status = AsyncMock(return_value={})
         client.fetch_link_preview = AsyncMock(return_value=(None, ""))
@@ -7199,6 +7202,98 @@ class TestPluginToolHandlers:
         assert result["unpinned"] is True
         client.unpin_message.assert_awaited_once_with("room-1", "msg-1")
 
+    # -- chatto_delete_message --
+
+    async def test_delete_message_refuses_a_read_only_room(self):
+        """Deleting changes what the room shows everyone, so it is a write: the
+        read-only promise for unlisted channels has to hold here too."""
+        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_delete_message,
+            {"room_id": "room-1", "event_id": "msg-1"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.delete_message.assert_not_awaited()
+
+    async def test_delete_message_refuses_a_room_the_bot_has_not_joined(self):
+        """A delete is a write, and the refusal names the action it blocked."""
+        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_delete_message,
+            {"room_id": "room-1", "event_id": "msg-1"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+
+        assert result["success"] is False
+        assert "cannot delete a message there" in result["error"]
+        assert "/join room-1" in result["error"]
+        client.delete_message.assert_not_awaited()
+
+    async def test_delete_message_requires_an_event_id(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_delete_message,
+            {"room_id": "room-1"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is False
+        assert "event_id" in result["error"]
+        client.delete_message.assert_not_awaited()
+
+    async def test_delete_message_refuses_missing_room(self):
+        client = self._stub_client()
+        result, _, _ = await self._run(
+            _handle_chatto_delete_message, {"event_id": "msg-1"}, client=client
+        )
+        assert result["success"] is False
+        assert "room_id" in result["error"]
+        client.delete_message.assert_not_awaited()
+
+    async def test_delete_message_deletes_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))
+        client.delete_message = AsyncMock(return_value=True)
+        result, _, _ = await self._run(
+            _handle_chatto_delete_message,
+            {"room_id": "room-1", "event_id": "msg-1"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is True
+        assert result["deleted"] is True
+        client.delete_message.assert_awaited_once_with("room-1", "msg-1")
+
+    async def test_delete_message_reports_the_server_refusal(self):
+        """The server decides who may delete what — a bot deleting someone
+        else's message gets the server's error back, not a fake success."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.delete_message = AsyncMock(
+            side_effect=ChattoError("permission_denied: not the message author")
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_delete_message,
+            {"room_id": "room-1", "event_id": "msg-1"},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["success"] is False
+        assert "Chatto API error" in result["error"]
+
     # -- chatto_send_ephemeral --
 
     async def test_send_ephemeral_posts_then_schedules_the_ttl(self):