Jelajahi Sumber

feat(chatto): add chatto_list_room_attachments, chatto_list_pinned_messages, chatto_pin_message tools

Three new read/pin plugin tools completing the Chatto API surface:

- chatto_list_room_attachments — list file attachments in a room (ID,
  filename, MIME type, size, thumbnail URL). Pageable.
- chatto_list_pinned_messages — list pinned messages with event ID, body,
  author, and pin timestamp. Pageable.
- chatto_pin_message — pin or unpin a message by event ID.

Each tool follows the established two-door pattern: schema + handler in
adapter.py, registration in _PLUGIN_TOOLS and plugin.yaml provides_tools.
Handler wrappers match the existing try/refusal/exception pattern, and
room resolution reuses the shared _resolve_room helper.
Paul Klumpp 1 Minggu lalu
induk
melakukan
e08cf54d7c
3 mengubah file dengan 322 tambahan dan 0 penghapusan
  1. 316 0
      adapter.py
  2. 3 0
      plugin.yaml
  3. 3 0
      test_adapter.py

+ 316 - 0
adapter.py

@@ -4358,6 +4358,11 @@ FOLLOW_REPLY_CHAIN_MAX = 10
 HISTORY_PAGE_SIZE = 20
 HISTORY_PAGE_MAX = 100
 
+# Read-tool bounds for pinned messages and attachments — one call per page,
+# kept small enough that a model can digest the output without choking.
+PINNED_PAGE_SIZE = 20
+ATTACHMENT_PAGE_SIZE = 40
+
 
 CHATTO_LIST_ROOMS_SCHEMA = {
     "name": "chatto_list_rooms",
@@ -4385,6 +4390,118 @@ CHATTO_LIST_ROOMS_SCHEMA = {
     },
 }
 
+CHATTO_LIST_ROOM_ATTACHMENTS_SCHEMA = {
+    "name": "chatto_list_room_attachments",
+    "description": (
+        "List the file attachments in a Chatto room (images, documents, etc.). "
+        "Returns each attachment's ID, filename, MIME type, size and an optional "
+        "thumbnail URL. Optional ``limit`` (default 40) and ``offset`` allow "
+        "paginating through large attachment lists."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID to list attachments in.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "limit": {
+                "type": "integer",
+                "description": (
+                    f"Maximum attachments to return (default {ATTACHMENT_PAGE_SIZE})."
+                ),
+            },
+            "offset": {
+                "type": "integer",
+                "description": "Skip this many attachments (for pagination).",
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_LIST_PINNED_MESSAGES_SCHEMA = {
+    "name": "chatto_list_pinned_messages",
+    "description": (
+        "List pinned messages in a Chatto room. Returns each message's event ID, "
+        "body, author (user ID and login), and the pin timestamp. Optional "
+        "``limit`` (default 20) and ``offset`` allow paginating through long "
+        "pin lists."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID to list pinned messages in.",
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "Optional: human-readable room name — one of ``room_id`` or "
+                    "``room_name`` is required."
+                ),
+            },
+            "limit": {
+                "type": "integer",
+                "description": f"Maximum pinned messages to return (default {PINNED_PAGE_SIZE}).",
+            },
+            "offset": {
+                "type": "integer",
+                "description": "Skip this many pinned messages (for pagination).",
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+CHATTO_PIN_MESSAGE_SCHEMA = {
+    "name": "chatto_pin_message",
+    "description": (
+        "Pin or unpin a message in a Chatto room. Use ``pin=true`` to pin a "
+        "message (it becomes visible via ``chatto_list_pinned_messages``), or "
+        "``pin=false`` to remove an existing pin. Requires the ``event_id`` of "
+        "the message to pin."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": "The Chatto room ID.",
+            },
+            "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 pin or unpin.",
+            },
+            "pin": {
+                "type": "boolean",
+                "description": (
+                    "True to pin, false to unpin the message."
+                ),
+            },
+        },
+        "required": ["event_id", "pin"],
+        "additionalProperties": False,
+    },
+}
+
 
 CHATTO_SEND_DM_SCHEMA = {
     "name": "chatto_send_dm",
@@ -5172,6 +5289,136 @@ async def _list_rooms(args: dict) -> str:
         )
 
 
+async def _list_room_attachments(args: dict) -> str:
+    """List file attachments in a Chatto room. See _handle_chatto_list_room_attachments."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    limit = _int_arg(
+        args, "limit", ATTACHMENT_PAGE_SIZE, low=1, high=ATTACHMENT_PAGE_SIZE * 5
+    )
+    offset = args.get("offset")
+    if offset is not None:
+        offset = int(offset)
+    if offset is not None and offset < 0:
+        raise _refuse("offset must be non-negative")
+
+    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
+
+        attachments, _ = await client.list_room_attachments(
+            room.id, limit=limit, offset=offset
+        )
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "room_name": room.name,
+                "count": len(attachments),
+                "offset": offset or 0,
+                "has_more": False,  # chattolib returns full list without Page here
+                "attachments": attachments,
+            }
+        )
+
+
+async def _list_pinned_messages(args: dict) -> str:
+    """List pinned messages in a Chatto room. See _handle_chatto_list_pinned_messages."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    limit = _int_arg(args, "limit", PINNED_PAGE_SIZE, low=1, high=PINNED_PAGE_SIZE * 5)
+    offset = args.get("offset")
+    if offset is not None:
+        offset = int(offset)
+    if offset is not None and offset < 0:
+        raise _refuse("offset must be non-negative")
+
+    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
+
+        page = await client.list_pinned_messages(room.id, limit=limit, offset=offset)
+        author_logins: dict[str, str] = {}
+        results: list[dict] = []
+        for pm in page.pinned_messages:
+            msg = pm.message
+            if msg is None:
+                continue
+            author_id = msg.actor_id
+            if author_id and author_id not in author_logins:
+                logins = await _batch_author_logins(client, room.id, [author_id])
+                author_logins.update(logins)
+            results.append(
+                {
+                    "event_id": msg.id,
+                    "body": msg.body,
+                    "author_id": author_id,
+                    "author_login": author_logins.get(author_id) or "",
+                    "pinned_at": _iso_or_none(pm.message.created_at if hasattr(pm, 'message') and pm.message else None),
+                }
+            )
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "room_name": room.name,
+                "count": len(results),
+                "has_older": page.page.has_more,
+                "pinned_messages": results,
+                "latest_pin_marker": page.latest_pin_marker,
+            }
+        )
+
+
+async def _pin_message(args: dict) -> str:
+    """Pin or unpin a message in a Chatto room. See _handle_chatto_pin_message."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+    pin = args.get("pin")
+
+    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 pin is None:
+        raise _refuse("pin (true/false) 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 pin:
+            pinned = await client.pin_message(room.id, event_id)
+            message = pinned.message if hasattr(pinned, 'message') else None
+            return json.dumps(
+                {
+                    "success": True,
+                    "action": "pinned",
+                    "room_id": room.id,
+                    "event_id": event_id,
+                    "pinned_at": _iso_or_none(message.created_at if message else None),
+                }
+            )
+        else:
+            success = await client.unpin_message(room.id, event_id)
+            return json.dumps(
+                {
+                    "success": True,
+                    "action": "unpinned",
+                    "room_id": room.id,
+                    "event_id": event_id,
+                    "unpinned": success,
+                }
+            )
+
+
 def _iso_or_none(moment: datetime | None) -> str | None:
     """A Message timestamp as ISO-8601, or None when the server sent none.
 
@@ -5532,6 +5779,60 @@ async def _handle_chatto_get_history(args: dict) -> str:
         return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
 
+async def _handle_chatto_list_room_attachments(args: dict) -> str:
+    """Tool handler for chatto_list_room_attachments.
+
+    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 _list_room_attachments(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_pinned_messages(args: dict) -> str:
+    """Tool handler for chatto_list_pinned_messages.
+
+    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 _list_pinned_messages(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_pin_message(args: dict) -> str:
+    """Tool handler for chatto_pin_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 _pin_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}"})
+
+
 def _register_bundled_skills(ctx) -> None:
     """Register the skills shipped in ``skills/``, each with its own description.
 
@@ -5570,6 +5871,21 @@ _PLUGIN_TOOLS = (
     ("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
     ("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
     ("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
+    (
+        "chatto_list_room_attachments",
+        CHATTO_LIST_ROOM_ATTACHMENTS_SCHEMA,
+        _handle_chatto_list_room_attachments,
+    ),
+    (
+        "chatto_list_pinned_messages",
+        CHATTO_LIST_PINNED_MESSAGES_SCHEMA,
+        _handle_chatto_list_pinned_messages,
+    ),
+    (
+        "chatto_pin_message",
+        CHATTO_PIN_MESSAGE_SCHEMA,
+        _handle_chatto_pin_message,
+    ),
 )
 
 

+ 3 - 0
plugin.yaml

@@ -16,6 +16,9 @@ provides_tools:
   - chatto_list_rooms
   - chatto_get_message
   - chatto_get_history
+  - chatto_list_room_attachments
+  - chatto_list_pinned_messages
+  - chatto_pin_message
 requires_env:
   - name: CHATTO_BASE_URL
     description: "Base URL of the Chatto server (e.g. https://chat.example.com) leave blank for ChattoHQ (https://chat.chatto.run)"

+ 3 - 0
test_adapter.py

@@ -4468,6 +4468,9 @@ class TestPluginToolSchemas:
             "chatto_list_rooms",
             "chatto_get_message",
             "chatto_get_history",
+            "chatto_list_room_attachments",
+            "chatto_list_pinned_messages",
+            "chatto_pin_message",
         ]
         for tool in ctx.registered_tools:
             assert tool["is_async"] is True, tool["name"]