Jelajahi Sumber

feat: add chatto_send_dm and chatto_send_room plugin tools with config fallback

Liv Klumpp 2 minggu lalu
induk
melakukan
bc4472056d
1 mengubah file dengan 326 tambahan dan 6 penghapusan
  1. 326 6
      adapter.py

+ 326 - 6
adapter.py

@@ -3287,6 +3287,12 @@ async def hermes_standalone_sender_fn(
             )
 
 
+# Resolved Chatto configuration — populated by ``hermes_validate_config`` so
+# plugin tools can access it without re-reading env vars or duplicating
+# ``ChattoConfiguration`` logic.
+_resolved_chatto_config: "ChattoConfiguration | None" = None
+
+
 # How similar an ``extra`` key must be to a declared config_key before the
 # unknown-key check reads it as a likely typo or renamed field.
 _NEAR_MISS_RATIO = 0.75
@@ -3326,20 +3332,22 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
     know that it is needed for plugin register(). Do not change signature.
     Takes ``config``. Compare to hermes_is_connected().
     """
-    chatto_config = ChattoConfiguration(pconfig=config)
+    # Store resolved config for plugin tools — avoids re-reading env/config.
+    global _resolved_chatto_config
+    _resolved_chatto_config = ChattoConfiguration(pconfig=config)
     _warn_on_unknown_extra_keys(config)
 
     if (
-        len(chatto_config.allowed_users.value) > 0
-        and chatto_config.allow_all_users.value
+        len(_resolved_chatto_config.allowed_users.value) > 0
+        and _resolved_chatto_config.allow_all_users.value
     ):
         logger.error(
             "Chatto: Conflicting configuration. Either use 'allowed_users' or 'allow_all_users' but not both."
         )
         return False
 
-    require_rooms = set(chatto_config.require_mention_rooms.value)
-    optional_rooms = set(chatto_config.optional_mention_rooms.value)
+    require_rooms = set(_resolved_chatto_config.require_mention_rooms.value)
+    optional_rooms = set(_resolved_chatto_config.optional_mention_rooms.value)
     overlap = sorted(require_rooms & optional_rooms)
     if overlap:
         logger.error(
@@ -3352,7 +3360,7 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
 
     # base_url always resolves (it defaults to ChattoHQ), so the only real
     # question is whether a token came in.
-    if chatto_config.token.value is not None:
+    if _resolved_chatto_config.token.value is not None:
         return True
     logger.error("Chatto: CHATTO_TOKEN must be set.")
     return False
@@ -3496,10 +3504,322 @@ def _capabilities() -> list[str]:
     return found
 
 
+# --------------------------------------------------------------------------- #
+# Plugin tools: send DM to a user, or send to a room
+# --------------------------------------------------------------------------- #
+
+CHATTO_SEND_DM_SCHEMA = {
+    "name": "chatto_send_dm",
+    "description": (
+        "Send a text message as a **Direct Message** to another Chatto user. "
+        "Looks up the user by `user_login` or `user_id`, creates or retrieves "
+        "a DM room, and posts the message there. Returns success with the "
+        "room_id and message_id, or an error."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "user_login": {
+                "type": "string",
+                "description": (
+                    "The Chatto login name of the recipient "
+                    "(e.g. 'nickk'). Exactly one of `user_login` or `user_id` is required."
+                ),
+            },
+            "user_id": {
+                "type": "string",
+                "description": (
+                    "The Chatto user ID of the recipient "
+                    "(e.g. 'Uxxxxxxxx'). Exactly one of `user_login` or `user_id` is required."
+                ),
+            },
+            "message": {
+                "type": "string",
+                "description": "The message text to send (markdown supported).",
+            },
+            "base_url": {
+                "type": "string",
+                "description": (
+                    "Chatto server base URL (e.g. 'https://chat.oberrhein.de'). "
+                    "If omitted, falls back to the configured server URL."
+                ),
+            },
+            "token": {
+                "type": "string",
+                "description": (
+                    "Chatto bot token (cht_BK_...). "
+                    "If omitted, falls back to the configured token."
+                ),
+            },
+        },
+        "required": ["message"],
+        "additionalProperties": False,
+    },
+}
+
+
+CHATTO_SEND_ROOM_SCHEMA = {
+    "name": "chatto_send_room",
+    "description": (
+        "Send a text message to a Chatto **room** (channel or DM room) "
+        "by its room ID or room name. Returns success with the message_id, "
+        "or an error if the room is not found or not joinable."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": (
+                    "The Chatto room ID of the destination "
+                    "(e.g. 'RbmwiHk4vs9a3Ql'). Exactly one of `room_id` or `room_name` is required."
+                ),
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "The exact display name of the room "
+                    "(case-insensitive). Exactly one of `room_id` or `room_name` is required."
+                ),
+            },
+            "message": {
+                "type": "string",
+                "description": "The message text to send (markdown supported).",
+            },
+            "base_url": {
+                "type": "string",
+                "description": (
+                    "Chatto server base URL (e.g. 'https://chat.oberrhein.de'). "
+                    "If omitted, falls back to the configured server URL."
+                ),
+            },
+            "token": {
+                "type": "string",
+                "description": (
+                    "Chatto bot token (cht_BK_...). "
+                    "If omitted, falls back to the configured token."
+                ),
+            },
+        },
+        "required": ["message"],
+        "additionalProperties": False,
+    },
+}
+
+
+async def _handle_chatto_send_dm(args: dict) -> dict:
+    """Tool handler for chatto_send_dm: resolve user, get/create DM room, send message."""
+    user_login = args.get("user_login") or ""
+    user_id = args.get("user_id") or ""
+    message = args.get("message") or ""
+
+    if not message:
+        return {"success": False, "error": "message is required"}
+
+    if not user_login and not user_id:
+        return {"success": False, "error": "Exactly one of user_login or user_id is required"}
+
+    # --- Read config: explicit args first, then fall back to resolved config ---
+    explicit_base_url = args.get("base_url") or ""
+    explicit_token = args.get("token") or ""
+
+    if explicit_token:
+        token = explicit_token
+    elif _resolved_chatto_config is not None:
+        token = _resolved_chatto_config.token.value or ""
+    else:
+        token = ""
+
+    if explicit_base_url:
+        base_url = explicit_base_url
+    elif _resolved_chatto_config is not None:
+        base_url = _resolved_chatto_config.base_url.value or "https://chat.chatto.run"
+    else:
+        base_url = "https://chat.chatto.run"
+    if not token:
+        return {"success": False, "error": "Chatto token not configured"}
+
+    try:
+        client = ChattoClient(base_url=base_url, token=token)
+    except Exception as exc:
+        return {"success": False, "error": f"Failed to create Chatto client: {exc}"}
+
+    try:
+        # 1. Resolve the target user
+        if user_login:
+            member_data = await client.admin_get_member(login=user_login)
+        else:
+            member_data = await client.admin_get_member(user_id=user_id)
+
+        member = member_data.get("member")
+        if not member:
+            return {"success": False, "error": f"User not found (login={user_login or user_id})"}
+
+        target_user = member.get("user")
+        if not target_user:
+            return {"success": False, "error": "User record missing in member data"}
+
+        target_user_id = target_user.get("id")
+        if not target_user_id:
+            return {"success": False, "error": "User has no ID"}
+
+        target_login = target_user.get("login", user_login)
+
+        # 2. Find or create the DM room between us and the target
+        our_id = (await client.me()).id
+
+        # Search existing rooms for a DM room with this user
+        existing_rooms = await client.list_rooms()
+        dm_room_id = None
+        for rws in existing_rooms:
+            room = rws.room
+            if not room or room.kind != RoomKind.DM:
+                continue
+            members_page, _ = await client.list_room_members(room.id)
+            if target_user_id in members_page and our_id in members_page:
+                dm_room_id = room.id
+                break
+
+        if dm_room_id is None:
+            # Create a new DM room
+            room = await client.start_dm(participant_ids=[our_id, target_user_id])
+            dm_room_id = room.id
+            logger.info("Chatto DM room created with @%s: %s", target_login, dm_room_id)
+
+        # 3. Post the message
+        normalised = _normalise_outbound_text(message)
+        chunks = BasePlatformAdapter.truncate_message(
+            normalised, ChattoConstants.SPLIT_THRESHOLD
+        )
+        message_ids = []
+        for chunk in chunks:
+            msg = await client.post_message(dm_room_id, chunk)
+            message_ids.append(msg.id)
+
+        return {
+            "success": True,
+            "dm_room_id": dm_room_id,
+            "recipient": target_login,
+            "recipient_user_id": target_user_id,
+            "message_ids": message_ids,
+            "chunks_sent": len(message_ids),
+        }
+
+    except ChattoAuthError as exc:
+        return {"success": False, "error": f"Authentication error: {exc}"}
+    except ChattoError as exc:
+        return {"success": False, "error": f"Chatto API error: {exc}"}
+    finally:
+        try:
+            await client.close()
+        except Exception:
+            pass
+
+
+async def _handle_chatto_send_room(args: dict) -> dict:
+    """Tool handler for chatto_send_room: resolve room, send message."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    message = args.get("message") or ""
+
+    if not message:
+        return {"success": False, "error": "message is required"}
+
+    if not room_id and not room_name:
+        return {"success": False, "error": "Exactly one of room_id or room_name is required"}
+
+    # --- Read config: explicit args first, then fall back to resolved config ---
+    explicit_token = args.get("token") or ""
+
+    if explicit_token:
+        token = explicit_token
+    elif _resolved_chatto_config is not None:
+        token = _resolved_chatto_config.token.value or ""
+    else:
+        token = ""
+
+    base_url = args.get("base_url") or ""
+    if not base_url:
+        base_url = _resolved_chatto_config.base_url.value or "https://chat.chatto.run" if _resolved_chatto_config is not None else "https://chat.chatto.run"
+    if not token:
+        return {"success": False, "error": "Chatto token not configured"}
+
+    try:
+        client = ChattoClient(base_url=base_url, token=token)
+    except Exception as exc:
+        return {"success": False, "error": f"Failed to create Chatto client: {exc}"}
+
+    try:
+        # 1. Resolve the target room
+        if not room_id and room_name:
+            # Look up room by name
+            rooms = await client.list_rooms()
+            found = None
+            for rws in rooms:
+                room = rws.room
+                if room and (room.name or "").casefold() == room_name.casefold():
+                    found = rws
+                    break
+            if not found or not found.room:
+                return {"success": False, "error": f"Room not found: {room_name}"}
+            room_id = found.room.id
+            room_label = found.room.name
+        else:
+            # Verify room exists
+            rws = await client.get_room(room_id)
+            if not rws or not rws.room:
+                return {"success": False, "error": f"Room not found: {room_id}"}
+            room_label = rws.room.name
+
+        # 2. Post the message
+        normalised = _normalise_outbound_text(message)
+        chunks = BasePlatformAdapter.truncate_message(
+            normalised, ChattoConstants.SPLIT_THRESHOLD
+        )
+        message_ids = []
+        for chunk in chunks:
+            msg = await client.post_message(room_id, chunk)
+            message_ids.append(msg.id)
+
+        return {
+            "success": True,
+            "room_id": room_id,
+            "room_name": room_label,
+            "message_ids": message_ids,
+            "chunks_sent": len(message_ids),
+        }
+
+    except ChattoAuthError as exc:
+        return {"success": False, "error": f"Authentication error: {exc}"}
+    except ChattoError as exc:
+        return {"success": False, "error": f"Chatto API error: {exc}"}
+    finally:
+        try:
+            await client.close()
+        except Exception:
+            pass
+
+
 def register(ctx) -> None:
     """Plugin entry point — called by the Hermes plugin system."""
     logger.info("Registering Chatto platform plugin on Hermes Agent")
 
+    # --- Plugin tools ---
+    ctx.register_tool(
+        name="chatto_send_dm",
+        toolset="chatto-platform",
+        schema=CHATTO_SEND_DM_SCHEMA,
+        handler=_handle_chatto_send_dm,
+        description=CHATTO_SEND_DM_SCHEMA["description"],
+    )
+    ctx.register_tool(
+        name="chatto_send_room",
+        toolset="chatto-platform",
+        schema=CHATTO_SEND_ROOM_SCHEMA,
+        handler=_handle_chatto_send_room,
+        description=CHATTO_SEND_ROOM_SCHEMA["description"],
+    )
+
     for capability in _capabilities():
         logger.info("Chatto capability: %s", capability)