|
|
@@ -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)
|
|
|
|