Paul Klumpp 3 дней назад
Родитель
Сommit
fa5125310c
7 измененных файлов с 340 добавлено и 319 удалено
  1. 39 0
      AGENTS.md
  2. 149 261
      adapter.py
  3. 14 12
      after-install.md
  4. 121 33
      platform_config.py
  5. 8 8
      plugin.yaml
  6. 7 4
      pyproject.toml
  7. 2 1
      test_adapter.py

+ 39 - 0
AGENTS.md

@@ -0,0 +1,39 @@
+# Agent Guidelines for hermes-chatto-plugin
+
+## Shell Scripts
+
+**Always run `shellcheck` after editing any shell scripts.**
+
+```bash
+shellcheck path/to/script.sh
+```
+
+Or validate all shell scripts in the project:
+
+```bash
+find . -name "*.sh" -exec shellcheck {} \;
+```
+
+### Configuration
+
+Project-specific shellcheck rules are defined in `.shellcheckrc`. Default severity is `error` to catch all issues.
+
+### Why
+
+- Prevents syntax errors and common pitfalls (e.g., missing quotes, unsafe variable expansions)
+- Ensures portability across different shell environments
+- Maintains code quality and security standards
+
+### Integration
+
+Consider adding a pre-commit hook for automatic validation:
+
+```yaml
+# .pre-commit-config.yaml
+repos:
+  - repo: https://github.com/koalaman/shellcheck-precommit
+    rev: v0.9.0
+    hooks:
+      - id: shellcheck
+        args: [--severity=error]
+```

+ 149 - 261
adapter.py

@@ -36,13 +36,12 @@ import hashlib
 import logging
 import logging
 import mimetypes
 import mimetypes
 import os
 import os
-import threading
 from collections import OrderedDict
 from collections import OrderedDict
 from datetime import datetime, timezone
 from datetime import datetime, timezone
+import time
 from typing import Any, Dict, List, Optional, Tuple, cast
 from typing import Any, Dict, List, Optional, Tuple, cast
 from urllib.parse import urlsplit, urlunsplit
 from urllib.parse import urlsplit, urlunsplit
 
 
-from plugins.plugin_utils import lazy_singleton
 import utils
 import utils
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -64,26 +63,19 @@ try:
     # Try vendored chattolib first
     # Try vendored chattolib first
     from vendor.chattolib.client import (
     from vendor.chattolib.client import (
         ChattoClient,
         ChattoClient,
-        ChattoError,
-        ChattoAuthError,
     )
     )
     from vendor.chattolib.exceptions import (
     from vendor.chattolib.exceptions import (
-        ChattoConnectError,
+        ChattoAuthError,
+        ChattoError,
     )
     )
     from vendor.chattolib.realtime import (
     from vendor.chattolib.realtime import (
         ChattoRealtimeError,
         ChattoRealtimeError,
         ChattoRealtimeCloseError,
         ChattoRealtimeCloseError,
-        RealtimeConnection,
         RealtimeEvent,
         RealtimeEvent,
-        ServerHello,
         stream_events,
         stream_events,
     )
     )
     from vendor.chattolib.types import (
     from vendor.chattolib.types import (
-        RoomKind,
         PresenceStatus,
         PresenceStatus,
-        RoomWithViewerState,
-        User,
-        Message,
     )
     )
     from vendor.chattolib._pb.chatto.api.v1 import (
     from vendor.chattolib._pb.chatto.api.v1 import (
         rooms_pb2 as room_service_pb2,
         rooms_pb2 as room_service_pb2,
@@ -95,33 +87,10 @@ except ImportError as e:
     logger.error("Chatto: failed to import vendored chattolib: %s", e)
     logger.error("Chatto: failed to import vendored chattolib: %s", e)
 
 
 
 
-from .platform_config import ChattoConfig
-
-
-class AsyncSingletonSlot:
-    """Thread-safe async singleton helper (extends SingletonSlot pattern for async factories)."""
-    def __init__(self):
-        self._instance = None
-        self._lock = threading.Lock()
-        self._future = None
-
-    async def get(self, factory):
-        """Get or create instance using async factory. Thread-safe with double-checked locking."""
-        if self._instance is not None:
-            return self._instance
-
-        with self._lock:
-            if self._instance is None:
-                if self._future is None:
-                    self._future = asyncio.ensure_future(factory())
-                return await self._future
-            return self._instance
+from .platform_config import (
+        ChattoConfiguration, ChattoConstants
+    )
 
 
-    def reset(self):
-        """Reset the singleton instance."""
-        with self._lock:
-            self._instance = None
-            self._future = None
 
 
 # --------------------------------------------------------------------------- #
 # --------------------------------------------------------------------------- #
 # Constants
 # Constants
@@ -212,15 +181,14 @@ class ChattoAdapter(BasePlatformAdapter):
     MAX_MESSAGE_LENGTH = 10000
     MAX_MESSAGE_LENGTH = 10000
     _SPLIT_THRESHOLD = 9900
     _SPLIT_THRESHOLD = 9900
     splits_long_messages = True
     splits_long_messages = True
+    supports_code_blocks: bool = True
 
 
-    _chatto_client: ChattoClient
-    
     def __init__(self, config: PlatformConfig, **kwargs):
     def __init__(self, config: PlatformConfig, **kwargs):
         """Signature needs to be compatible with BasePlatformAdapter.__init__ """
         """Signature needs to be compatible with BasePlatformAdapter.__init__ """
-        # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values
-        chatto_config: ChattoConfig = ChattoConfig(config.extra)
+        # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values insode PlatformConfig.
+        chatto_config: ChattoConfiguration = ChattoConfiguration(config)
 
 
-        super().__init__(config=config, platform=Platform(chatto_config._PLATFORM))
+        super().__init__(config=config, platform=Platform(ChattoConstants.PLATFORM_ID))
 
 
         # --- Configuration from our configuration data class with some logic ---
         # --- Configuration from our configuration data class with some logic ---
         self.chatto_config = chatto_config
         self.chatto_config = chatto_config
@@ -255,37 +223,44 @@ class ChattoAdapter(BasePlatformAdapter):
         # Member directory cache: user_id -> user info dict
         # Member directory cache: user_id -> user info dict
         self._user_cache: Dict[str, dict] = {}
         self._user_cache: Dict[str, dict] = {}
 
 
+        # Chattolib client cache and lock for async access.
+        self._chatto_client: Optional[ChattoClient] = None
+        self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
+
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     # Auth
     # Auth
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
 
 
     async def _get_chatto_client(self: ChattoAdapter) -> Optional[ChattoClient]:
     async def _get_chatto_client(self: ChattoAdapter) -> Optional[ChattoClient]:
-        """Get or create a ChattoClient instance using thread-safe async singleton."""
-        # Fast path: return cached client if available
+        """Get or create a ChattoClient instance."""
+
         if self._chatto_client is not None:
         if self._chatto_client is not None:
             return self._chatto_client
             return self._chatto_client
-        
-        if not self.chatto_config._base_url or not self.chatto_config._login or not self.chatto_config._password:
-            logger.error("Chatto: missing configuration (URL, login, or password)")
-            return None
-        
-        try:
-            client = await self._chatto_client.get(
-                lambda: ChattoClient.login(
-                    self.chatto_config._login,
-                    self.chatto_config._password,
+
+        async with self._chatto_client_lock:
+            if self._chatto_client is not None:
+                return self._chatto_client
+
+            try:
+                self.chatto_config._ensure_configuration() # throws ValueError .. or does just nothing.
+                assert(self.chatto_config._login) # now we can assume, _login is available.
+
+                client = await self._open_client(
                     base_url=self.chatto_config._base_url,
                     base_url=self.chatto_config._base_url,
+                    login=self.chatto_config._login,
+                    password=self.chatto_config._password,
+                    token=self.chatto_config._token,
                 )
                 )
-            )
-            self._chatto_client = client  # Cache for direct access
-            logger.info("Chatto: logged in as %s via chattolib", self._login)
-            return client
-        except ChattoAuthError as e:
-            logger.error("Chatto: authentication failed: %s", e)
-            return None
-        except Exception as e:
-            logger.error("Chatto: failed to create client: %s", e)
-            return None
+                self._chatto_client = client
+                self._token = client.token
+                logger.info("Chatto: logged in as %s via chattolib", self.chatto_config._login)
+                return client
+            except ChattoAuthError as e:
+                logger.error("Chatto: authentication failed: %s", e)
+                return None
+            except (ChattoError, ValueError) as e:
+                logger.error("Chatto: failed to create client: %s", e)
+                return None
 
 
     async def _require_client(self) -> Any:
     async def _require_client(self) -> Any:
         """Return a ChattoClient or raise RuntimeError if unavailable.
         """Return a ChattoClient or raise RuntimeError if unavailable.
@@ -299,21 +274,13 @@ class ChattoAdapter(BasePlatformAdapter):
             raise RuntimeError("Chatto client unavailable")
             raise RuntimeError("Chatto client unavailable")
         return cast(Any, client)
         return cast(Any, client)
 
 
-    async def _client(self) -> Optional[ChattoClient]:
-        """Helper to get the chattolib client. Thread-safe via AsyncSingletonSlot."""
-        return await self._get_chatto_client()
-
     async def _ensure_token(self) -> bool:
     async def _ensure_token(self) -> bool:
-        """Login via chattolib."""
-        if self.chatto_config._token:
+        """Ensure we have a logged-in Chatto client and token."""
+        if self.chatto_config._token and self._chatto_client is not None:
             return True
             return True
-        
+
         client = await self._get_chatto_client()
         client = await self._get_chatto_client()
-        if client is not None:
-            self._token = client.token
-            return True
-        
-        return False
+        return client is not None
 
 
     async def _relogin(self) -> bool:
     async def _relogin(self) -> bool:
         """Force re-login (token expired)."""
         """Force re-login (token expired)."""
@@ -326,10 +293,10 @@ class ChattoAdapter(BasePlatformAdapter):
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     async def _open_client(
     async def _open_client(
         *,
         *,
-        base_url: str,
-        login: str,
-        password: str,
-        token: Optional[str] = None,
+            base_url: str,
+            login: str,
+            password: str,
+            token: Optional[str] = None,
     ) -> Any:
     ) -> Any:
         """Return a connected ``ChattoClient`` using token or login/password."""
         """Return a connected ``ChattoClient`` using token or login/password."""
 
 
@@ -338,43 +305,9 @@ class ChattoAdapter(BasePlatformAdapter):
         return await ChattoClient.login(login, password, base_url=base_url)
         return await ChattoClient.login(login, password, base_url=base_url)
     
     
     async def connect(self, *, is_reconnect: bool = False) -> bool:
     async def connect(self, *, is_reconnect: bool = False) -> bool:
-        """Log into Chatto and start the realtime event stream. Codemancer"""
-        if not self.chatto_config._base_url:
-            logger.error("Chatto: CHATTO_BASE_URL not configured")
-            return False
-        if not self.chatto_config._login and not self.chatto_config._password:
-            logger.error(
-                "Chatto: CHATTO_LOGIN/PASSWORD is not set"
-            )
-            return False
-
-        self._closing = False
+        """Connect to Chatto and start the realtime event stream."""
 
 
-        try:
-            self._client = await self._open_client(
-                base_url=self.chatto_config._base_url,
-                login=self.chatto_config._login,
-                password=self.chatto_config._password,
-                token=self.chatto_config._token,
-            )
-        except Exception as exc:
-            logger.error("Chatto: failed to construct client: %s", exc)
-            return False
-
-        # Best-effort presence broadcast (non-fatal on failure).
-        try:
-            await self._client.update_presence(_PRESENCE_ONLINE)
-        except Exception:
-            logger.debug("Chatto: update_presence not supported / failed (non-fatal)")
-
-        self._stream_task = asyncio.create_task(
-            self._run_event_stream(), name="chatto-event-stream",
-        )
-        self._mark_connected()
-        return True
 
 
-    async def connect_too_long(self, *, is_reconnect: bool = False) -> bool:
-        """Login, discover rooms, start WebSocket realtime connection."""
         if not await self._ensure_token():
         if not await self._ensure_token():
             return False
             return False
 
 
@@ -383,7 +316,7 @@ class ChattoAdapter(BasePlatformAdapter):
         except RuntimeError:
         except RuntimeError:
             self._set_fatal_error("connect_failed", "Chatto client not available", retryable=True)
             self._set_fatal_error("connect_failed", "Chatto client not available", retryable=True)
             return False
             return False
-        
+
         # Get our own user info
         # Get our own user info
         try:
         try:
             me = await client.me()
             me = await client.me()
@@ -421,6 +354,7 @@ class ChattoAdapter(BasePlatformAdapter):
         except Exception as e:
         except Exception as e:
             logger.error("Chatto: failed to list rooms: %s", e)
             logger.error("Chatto: failed to list rooms: %s", e)
             return False
             return False
+
         all_room_ids = []
         all_room_ids = []
         for entry in rooms:
         for entry in rooms:
             room = entry.get("room", {})
             room = entry.get("room", {})
@@ -434,15 +368,15 @@ class ChattoAdapter(BasePlatformAdapter):
             viewer = entry.get("viewerState", {})
             viewer = entry.get("viewerState", {})
             is_member = viewer.get("isMember", False)
             is_member = viewer.get("isMember", False)
             # If user-specified channels, only watch those; otherwise watch all joined rooms
             # If user-specified channels, only watch those; otherwise watch all joined rooms
-            if self._channel_ids:
-                if rid in self.chatto_config._channel_ids and not is_member:
+            if self.chatto_config._channels_list:
+                if rid in self.chatto_config._channels_list and not is_member:
                     await self._join_room(rid)
                     await self._join_room(rid)
                 all_room_ids.append(rid)
                 all_room_ids.append(rid)
             elif is_member:
             elif is_member:
                 all_room_ids.append(rid)
                 all_room_ids.append(rid)
 
 
-        if self.chatto_config._channel_ids:
-            watch = list(self.chatto_config._channel_ids)
+        if self.chatto_config._channels_list:
+            watch = list(self.chatto_config._channels_list)
         else:
         else:
             watch = all_room_ids
             watch = all_room_ids
 
 
@@ -474,7 +408,7 @@ class ChattoAdapter(BasePlatformAdapter):
         self._start_liveness_probe()
         self._start_liveness_probe()
         logger.info(
         logger.info(
             "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
             "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
-            self._base_url,
+            self.chatto_config._base_url,
             self._user_display or self._user_login,
             self._user_display or self._user_login,
             len(watch),
             len(watch),
         )
         )
@@ -514,7 +448,6 @@ class ChattoAdapter(BasePlatformAdapter):
             self._ws_task = None
             self._ws_task = None
         self._token = None
         self._token = None
         self._chatto_client = None
         self._chatto_client = None
-        self._client_slot.reset()
 
 
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     # Liveness probe
     # Liveness probe
@@ -667,7 +600,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
     def _websocket_url(self) -> str:
     def _websocket_url(self) -> str:
         """Build the WebSocket URL from the base HTTP URL."""
         """Build the WebSocket URL from the base HTTP URL."""
-        parsed = urlsplit(self.chatto_config._base_url.strip())
+        parsed = urlsplit(self.chatto_config._base_url)
         scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
         scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
         if scheme not in ("ws", "wss") or not parsed.netloc:
         if scheme not in ("ws", "wss") or not parsed.netloc:
             raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
             raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
@@ -709,83 +642,83 @@ class ChattoAdapter(BasePlatformAdapter):
         # Ensure ws_ready is available for synchronization with starter
         # Ensure ws_ready is available for synchronization with starter
         assert self._ws_ready is not None
         assert self._ws_ready is not None
         backoff = _WS_RECONNECT_INITIAL_BACKOFF
         backoff = _WS_RECONNECT_INITIAL_BACKOFF
-        
-        try:
-            while True:
-                try:
-                    logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
+    
+        logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
+        while True:
+            try:
+                
+                # Start streaming events
+                async for event in stream_events(client):
+                    # Signal that we're connected and ready
+                    if not self._ws_ready.is_set():
+                        self._ws_active = True
+                        self._ws_ready.set()
+                        backoff = _WS_RECONNECT_INITIAL_BACKOFF
+
+                    # Handle different event kinds
+                    if event.kind == "projection_event":
+                        # Convert chattolib RealtimeEvent to our format
+                        # event.payload is the RealtimeProjectionEvent protobuf
+                        try:
+                            # Extract the raw bytes for compatibility with existing handler
+                            # For now, we'll use the existing _handle_projection_event
+                            # which expects bytes. We need to convert.
+                            # 
+                            # Actually, let's create a new handler that works with
+                            # chattolib's event objects directly.
+                            await self._handle_chattolib_projection_event(event)
+                        except Exception as e:
+                            logger.warning("Chatto: failed to handle projection event: %s", e)
                     
                     
-                    # Start streaming events
-                    async for event in stream_events(client):
-                        # Signal that we're connected and ready
-                        if not self._ws_ready.is_set():
-                            self._ws_active = True
-                            self._ws_ready.set()
-                            backoff = _WS_RECONNECT_INITIAL_BACKOFF
-
-                        # Handle different event kinds
-                        if event.kind == "projection_event":
-                            # Convert chattolib RealtimeEvent to our format
-                            # event.payload is the RealtimeProjectionEvent protobuf
-                            try:
-                                # Extract the raw bytes for compatibility with existing handler
-                                # For now, we'll use the existing _handle_projection_event
-                                # which expects bytes. We need to convert.
-                                # 
-                                # Actually, let's create a new handler that works with
-                                # chattolib's event objects directly.
-                                await self._handle_chattolib_projection_event(event)
-                            except Exception as e:
-                                logger.warning("Chatto: failed to handle projection event: %s", e)
-                        
-                        elif event.kind == "caught_up":
-                            # Update resume cursor
-                            if hasattr(event.payload, 'cursor'):
-                                self._resume_cursor = event.payload.cursor
-                            logger.debug("Chatto: caught_up received, cursor=%s", self._resume_cursor or "(none)")
-                        
-                        elif event.kind in ("message_posted", "mention_notification", 
-                                           "new_direct_message_notification", "user_joined_room",
-                                           "room_created", "user_left_room", "message_edited",
-                                           "message_retracted", "session_terminated"):
-                            # Transient events - convert to envelope format for existing handler
-                            await self._handle_chattolib_transient_event(event)
-                        
-                        elif event.kind in ("heartbeat", "pong", "subscribed"):
-                            # Ignore these
-                            logger.debug("Chatto: %s event received", event.kind)
-                        
-                        elif event.kind == "error":
-                            logger.warning("Chatto: server error event: %s", event.payload)
-                        
-                        elif event.kind == "close":
-                            logger.info("Chatto: server sent close event")
-                            raise ConnectionError("Server closed connection")
-                        
-                        else:
-                            logger.debug("Chatto: unknown event kind: %s", event.kind)
+                    elif event.kind == "caught_up":
+                        # Update resume cursor
+                        if hasattr(event.payload, 'cursor'):
+                            self._resume_cursor = event.payload.cursor
+                        logger.debug("Chatto: caught_up received, cursor=%s", self._resume_cursor or "(none)")
                     
                     
-                except ChattoRealtimeCloseError as e:
-                    logger.warning("Chatto: realtime closed by server: %s (reconnect=%s)", e.message, e.reconnect)
-                    if e.reconnect:
-                        self._ws_active = False
-                        await asyncio.sleep(backoff)
-                        backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
-                        continue
-                    raise
-                except ChattoRealtimeError as e:
-                    logger.warning("Chatto: realtime error: %s (fatal=%s)", e.message, e.fatal)
-                    if e.fatal:
-                        raise
-                except (ConnectionError, asyncio.CancelledError):
-                    raise
-                except Exception as e:
+                    elif event.kind in ("message_posted", "mention_notification", 
+                                        "new_direct_message_notification", "user_joined_room",
+                                        "room_created", "user_left_room", "message_edited",
+                                        "message_retracted", "session_terminated"):
+                        # Transient events - convert to envelope format for existing handler
+                        await self._handle_chattolib_transient_event(event)
+                    
+                    elif event.kind in ("heartbeat", "pong", "subscribed"):
+                        # Ignore these
+                        logger.debug("Chatto: %s event received", event.kind)
+                    
+                    elif event.kind == "error":
+                        logger.warning("Chatto: server error event: %s", event.payload)
+                    
+                    elif event.kind == "close":
+                        logger.info("Chatto: server sent close event")
+                        raise ConnectionError("Server closed connection")
+                    
+                    else:
+                        logger.debug("Chatto: unknown event kind: %s", event.kind)
+                
+            except ChattoRealtimeCloseError as e:
+                logger.warning("Chatto: realtime closed by server: %s (reconnect=%s)", e.message, e.reconnect)
+                if e.reconnect:
                     self._ws_active = False
                     self._ws_active = False
-                    logger.warning("Chatto: event stream error: %s, retrying in %.1fs", e, backoff)
                     await asyncio.sleep(backoff)
                     await asyncio.sleep(backoff)
                     backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
                     backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
-        finally:
-            self._ws_active = False
+                    continue
+                raise
+            except ChattoRealtimeError as e:
+                logger.warning("Chatto: realtime error: %s (fatal=%s)", e.message, e.fatal)
+                if e.fatal:
+                    raise
+            except (ChattoError, asyncio.CancelledError):
+                raise
+            except Exception as e:
+                self._ws_active = False
+                logger.warning("Chatto: event stream error: %s, retrying in %.1fs", e, backoff)
+                await asyncio.sleep(backoff)
+                backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
+
+            time.sleep(1)
+
 
 
     async def _handle_chattolib_projection_event(self, event: RealtimeEvent) -> None:
     async def _handle_chattolib_projection_event(self, event: RealtimeEvent) -> None:
         """Handle a chattolib RealtimeEvent with kind='projection_event'.
         """Handle a chattolib RealtimeEvent with kind='projection_event'.
@@ -1188,7 +1121,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
         if self.chatto_config._require_mention and not is_dm and not mentioned:
         if self.chatto_config._require_mention and not is_dm and not mentioned:
             # Allow free-response rooms (like Discord's free_response_channels)
             # Allow free-response rooms (like Discord's free_response_channels)
-            if room_id not in self.chatto_config._free_response_channels:
+            if room_id not in self.chatto_config._free_response_channels_list:
                 return
                 return
 
 
         # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
         # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
@@ -1983,21 +1916,8 @@ class ChattoAdapter(BasePlatformAdapter):
             text = f"{caption}\n{image_url}"
             text = f"{caption}\n{image_url}"
         return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
         return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
 
 
-    # ------------------------------------------------------------------ #
-    # Platform properties
-    # ------------------------------------------------------------------ #
-
-    @property
-    def platform_name(self) -> str:
-        return "chatto"
 
 
-    @property
-    def supports_markdown(self) -> bool:
-        return True
 
 
-    @property
-    def supports_reactions(self) -> bool:
-        return True
 
 
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     # Member directory — user lookup and mention resolution (Chatto-unique)
     # Member directory — user lookup and mention resolution (Chatto-unique)
@@ -2029,12 +1949,9 @@ class ChattoAdapter(BasePlatformAdapter):
                         self._user_cache[uid] = user_dict
                         self._user_cache[uid] = user_dict
                         users.append(user_dict)
                         users.append(user_dict)
             return users
             return users
-        except ChattoError as e:
+        except (ChattoError, Exception) as e:
             logger.debug("Chatto: ListUsers failed: %s", e)
             logger.debug("Chatto: ListUsers failed: %s", e)
             return []
             return []
-        except Exception as e:
-            logger.debug("Chatto: ListUsers error: %s", e)
-            return []
 
 
     async def get_user(self, user_id: str) -> Optional[dict]:
     async def get_user(self, user_id: str) -> Optional[dict]:
         """Get a single user by ID via UserService/GetUser.
         """Get a single user by ID via UserService/GetUser.
@@ -2223,9 +2140,7 @@ def check_requirements() -> bool:
  
  
     # Then check configuration
     # Then check configuration
     return bool(
     return bool(
-        os.getenv("CHATTO_URL", "").strip()
-        and os.getenv("CHATTO_LOGIN", "").strip()
-        and os.getenv("CHATTO_PASSWORD", "").strip()
+        ChattoConfiguration._ensure_configuration
     )
     )
 
 
 
 
@@ -2283,34 +2198,6 @@ def _apply_yaml_config(yaml_cfg: dict, chatto_cfg: dict) -> Optional[dict]:
     return None
     return None
 
 
 
 
-def _env_enablement() -> Optional[dict]:
-    """Seed PlatformConfig.extra from env vars for env-only setups.
-
-    Returns a dict compatible with the PlatformConfig merge hook (or None
-    when no env-provided values are present).
-    """
-    extra_dict: dict = {}
-
-    env_url = os.getenv("CHATTO_URL", "").strip()
-    if env_url:
-        extra_dict["url"] = env_url
-
-    env_channels = os.getenv("CHATTO_CHANNELS", "").strip()
-    if env_channels:
-        extra_dict["channels"] = [c.strip() for c in env_channels.split(",") if c.strip()]
-   
-    env_require_mention_str = str(utils.is_truthy_value(os.getenv("CHATTO_REQUIRE_MENTION", "")))
-    extra_dict["require_mention"] = env_require_mention_str
-
-    env_home_channel = os.getenv("CHATTO_HOME_CHANNEL", "").strip()
-    if env_home_channel:
-        home_dict = {"home_channel": env_home_channel}
-
-
-    return {**extra_dict, **home_dict}
-
-
-
 async def _standalone_send(
 async def _standalone_send(
     pconfig,
     pconfig,
     chat_id,
     chat_id,
@@ -2326,7 +2213,7 @@ async def _standalone_send(
     """
     """
     pconfig.getattr("extra", {})
     pconfig.getattr("extra", {})
 
 
-    chatto_config = ChattoConfig(extra=pconfig.extra)
+    chatto_config = ChattoConfiguration(pconfig=pconfig)
 
 
     # Create a temporary client for standalone sending
     # Create a temporary client for standalone sending
     client: ChattoClient
     client: ChattoClient
@@ -2337,15 +2224,19 @@ async def _standalone_send(
     if not chatto_config._base_url or (not (login or not password) or not token):
     if not chatto_config._base_url or (not (login or not password) or not token):
         return SendResult(success=False, error="Chatto: base URL or credentials missing")
         return SendResult(success=False, error="Chatto: base URL or credentials missing")
     
     
-    try:
+    try: 
+        chatto_config._ensure_configuration()
         if token:
         if token:
             client = ChattoClient(base_url=chatto_config._base_url, token=token)
             client = ChattoClient(base_url=chatto_config._base_url, token=token)
         else:
         else:
-            client = await ChattoClient.login(
-                base_url=chatto_config._base_url, login=chatto_config._login, password=chatto_config._password,
-            )
-    except Exception as exc:
+            if login and password:
+                client = await ChattoClient.login(
+                    base_url=chatto_config._base_url, login=login, password=password,
+                )
+    except (Exception, ValueError) as exc:
         return SendResult(success=False, error=f"Chatto login failed: {exc}")
         return SendResult(success=False, error=f"Chatto login failed: {exc}")
+    finally:
+        logger.debug("Chatto standalone client: {client}")
 
 
     try:
     try:
         kwargs: Dict[str, Any] = {}
         kwargs: Dict[str, Any] = {}
@@ -2361,10 +2252,8 @@ async def _standalone_send(
     finally:
     finally:
         try:
         try:
             await client.close()
             await client.close()
-        except Exception:
-            logger.error("Chatto standalone: error closing short-lived client")
-
-        await client.login(login=login, password=password)
+        except Exception as exc:
+            logger.error("Chatto standalone: error closing short-lived client. Perhaps already closed. {exc}")
 
 
 
 
 def interactive_setup() -> None:
 def interactive_setup() -> None:
@@ -2384,9 +2273,9 @@ def interactive_setup() -> None:
         def set_env_var(k: str, v: str) -> None:
         def set_env_var(k: str, v: str) -> None:
             os.environ[k] = v
             os.environ[k] = v
 
 
-    url = prompt_env("Chatto server URL (e.g. https://chat.example.com):")
+    url = prompt_env("Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
     if url:
     if url:
-        set_env_var("CHATTO_URL", url)
+        set_env_var("CHATTO_BASE_URL", url)
     login = prompt_env("Chatto login (username):")
     login = prompt_env("Chatto login (username):")
     if login:
     if login:
         set_env_var("CHATTO_LOGIN", login)
         set_env_var("CHATTO_LOGIN", login)
@@ -2408,22 +2297,21 @@ def interactive_setup() -> None:
 def register(ctx) -> None:
 def register(ctx) -> None:
     """Plugin entry point — called by the Hermes plugin system."""
     """Plugin entry point — called by the Hermes plugin system."""
     ctx.register_platform(
     ctx.register_platform(
-        name="chatto",
-        label="Chatto Chat Server",
+        name=ChattoConstants.PLATFORM_NAME,
+        label=ChattoConstants.PLATFORM_LABEL,
         adapter_factory=lambda cfg: ChattoAdapter(cfg),
         adapter_factory=lambda cfg: ChattoAdapter(cfg),
         check_fn=check_requirements,
         check_fn=check_requirements,
         validate_config=validate_config,
         validate_config=validate_config,
         is_connected=is_connected,
         is_connected=is_connected,
-        required_env=["CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
+        required_env=["CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
         install_hint="Requires a Chatto server. See https://docs.chatto.run",
         install_hint="Requires a Chatto server. See https://docs.chatto.run",
-        env_enablement_fn=_env_enablement,
+        env_enablement_fn=ChattoConfiguration.env_only_to_seed_extra,
         setup_fn=interactive_setup,
         setup_fn=interactive_setup,
         apply_yaml_config_fn=_apply_yaml_config,
         apply_yaml_config_fn=_apply_yaml_config,
         cron_deliver_env_var="CHATTO_HOME_CHANNEL",
         cron_deliver_env_var="CHATTO_HOME_CHANNEL",
         standalone_sender_fn=_standalone_send,
         standalone_sender_fn=_standalone_send,
         allowed_users_env="CHATTO_ALLOWED_USERS",
         allowed_users_env="CHATTO_ALLOWED_USERS",
         allow_all_env="CHATTO_ALLOW_ALL_USERS",
         allow_all_env="CHATTO_ALLOW_ALL_USERS",
-        auto_thread_env="CHATTO_AUTO_THREAD",
         max_message_length=_MAX_MESSAGE_LENGTH,
         max_message_length=_MAX_MESSAGE_LENGTH,
         emoji="💬",
         emoji="💬",
         allow_update_command=True,
         allow_update_command=True,

+ 14 - 12
after-install.md

@@ -1,29 +1,31 @@
 ## Chatto Plugin Installed ✅
 ## Chatto Plugin Installed ✅
 
 
-Next steps:
+Next steps. Edit your `~/.hermes/.env` OR `~/.hermes/config.yaml` 
 
 
-1. **Add credentials** to `~/.hermes/.env`:
+Environment Variables will take precedence over config.yaml entries.
+
+1. Required connection details
    ```
    ```
-   CHATTO_URL=https://chat.example.com
    CHATTO_LOGIN=your-username
    CHATTO_LOGIN=your-username
    CHATTO_PASSWORD=your-password
    CHATTO_PASSWORD=your-password
    ```
    ```
-
-2. **Find your home channel ID** — in Chatto, right-click a room → Copy ID, or use the API:
-   ```bash
-   curl -s -X POST https://chat.example.com/auth/login \
-     -H "Content-Type: application/json" \
-     -d '{"login":"your-username","password":"your-password"}'
+   To connect to your server, set `CHATTO_BASE_URL`. Default, when not set,
+   is ChattoHQ (`https://chat.chatto.run`)
    ```
    ```
+   CHATTO_BASE_URL=https://chat.example.com
+   ```
+
+2. Home Channel
 
 
-3. **Set the home channel** (optional, defaults to first room):
+   Set your Home Channel using the Channel ID** (optional, defaults to first room):
    ```bash
    ```bash
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE
    ```
    ```
 
 
-4. **Restart the gateway**:
+3. Restart the Hermes Gateway
    ```bash
    ```bash
    hermes gateway restart
    hermes gateway restart
    ```
    ```
 
 
-5. **Test** — send a message in a Chatto room mentioning your bot's username.
+4. Test
+   send a message in a Chatto room mentioning your bot's username.

+ 121 - 33
platform_config.py

@@ -4,16 +4,65 @@ Chatto Platform Config
 
 
 """
 """
 from dataclasses import dataclass
 from dataclasses import dataclass
+from email.policy import default
 import os
 import os
-from typing import Optional
+from typing import Any, Optional
 
 
+from gateway.config import PlatformConfig
 import utils
 import utils
 
 
+from vendor.chattolib.client import ChattoClient
 
 
+class ChattoConstants:
+    """
+    Chatto Platform Constants
+    """
+    PLATFORM_ID: str = "CHATTO"
+    PLATFORM_NAME: str = "chatto-platform"
+    PLATFORM_LABEL: str = "Chatto"
+
+
+
+def _get_env_or_extra_str(env_var: str, extra_val: Optional[str]) -> Optional[str]:
+    """Get a value from environment variable or extra config."""
+    env_value = os.getenv(env_var)
+    if env_value is not None:
+        return env_value.strip()
+
+    if extra_val is not None:
+        return extra_val.strip()
+
+    return None
+
+
+def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str], default: bool = False) -> bool:
+    """Get a boolean value from environment variable or extra config."""
+    return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val), default)
+
+
+def _split_str_to_list(mystring: str) -> list:
+    return list(c.strip() for c in mystring.split(","))
+
+
+def _get_env_or_extra_list(env_var: str, extra_val: Optional[set]) -> list[str]:
+    """Get a list of values from environment variable or extra config."""
+    env_value = os.getenv(env_var)
+    if env_value is not None:
+        return _split_str_to_list(env_value)
+
+    if extra_val is not None:
+        if isinstance(extra_val, list):
+            return list(
+                c.strip() for c in extra_val
+            )
+        if isinstance(extra_val, str):
+            return _split_str_to_list(extra_val)
+
+    return []
 
 
 
 
 @dataclass
 @dataclass
-class ChattoConfig:
+class ChattoConfiguration:
     """
     """
     Chatto Platform Config
     Chatto Platform Config
 
 
@@ -21,50 +70,89 @@ class ChattoConfig:
     """
     """
 
 
     _token: Optional[str] = None
     _token: Optional[str] = None
-    _PLATFORM: str = "CHATTO"
-
-    def __init__(self, extra: dict = {}):
+        
 
 
-        self._base_url = os.getenv("CHATTO_BASE_URL", "").strip() # todo: add config.yaml support
-        self._token = os.getenv("CHATTO_TOKEN", "").strip() # todo: add config.yaml support 
-        self._login = os.getenv("CHATTO_LOGIN", "").strip() # todo: add config.yaml support
-        self._password = os.getenv("CHATTO_PASSWORD", "").strip() # todo: add config.yaml support
+    def __init__(self, pconfig: PlatformConfig):
+        """PlatformConfig from Hermes provides our own configuration within the "extra" 
+        object. But here, we allow overriding via environment variables again.
 
 
-        self._raw_channels = os.getenv("CHATTO_CHANNELS", "").strip() # todo: add config.yaml support
+        We take our Configuration from this typed Class, because it is easier access than using extra.get["base_url"].
+        """
+        self._base_url = _get_env_or_extra_str("CHATTO_BASE_URL", pconfig.extra.get("base_url")) or ChattoClient.DEFAULT_BASE_URL
 
 
-        if self._raw_channels:
-            self._channel_ids = [c.strip() for c in self._raw_channels.split(",") if c.strip()]
-        elif isinstance(extra.get("channels"), list):
-            self._channel_ids = [str(c) for c in extra["channels"]]
-        else:
-            self._channel_ids = []
+        self._token = _get_env_or_extra_str("CHATTO_TOKEN", pconfig.extra.get("token"))
+        self._login = _get_env_or_extra_str("CHATTO_LOGIN", pconfig.extra.get("login"))
+        self._password = _get_env_or_extra_str("CHATTO_PASSWORD", pconfig.extra.get("password"))
 
 
+        self._channels_str = _get_env_or_extra_str("CHATTO_CHANNELS", pconfig.extra.get("channels"))
+        self._channels_list = _get_env_or_extra_list("CHATTO_CHANNELS", pconfig.extra.get("channels"))
 
 
-        self._home_channel = (
-            os.getenv("CHATTO_HOME_CHANNEL", "").strip()
-            or str(extra.get("home_channel", "")).strip()
-        )
+        self._home_channel = _get_env_or_extra_str("CHATTO_HOME_CHANNEL", pconfig.extra.get("home_channel"))
 
 
-        self._require_mention = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
-        if self._require_mention:
-            self._require_mention = self._require_mention in ("true", "1", "yes")
-        else:
-            self._require_mention = bool(extra.get("require_mention", True))
+        self._require_mention = _get_env_or_extra_truthy("CHATTO_REQUIRE_MENTION", pconfig.extra.get("require_mention"))
 
 
         # free_response_channels: room IDs where the bot responds without being mentioned via "@botname"
         # free_response_channels: room IDs where the bot responds without being mentioned via "@botname"
-        _free_response = os.getenv("CHATTO_FREE_RESPONSE_CHANNELS", "").strip()
-        if _free_response:
-            self._free_response_channels = set(c.strip() for c in _free_response.split(",") if c.strip())
-        else:
-            self._free_response_channels = set(
-                str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
-            )
+        self._free_response_channels_list = _get_env_or_extra_list("CHATTO_FREE_RESPONSE_CHANNELS", pconfig.extra.get("free_response_channels"))
 
 
         # Auto-thread: by default, Chatto creates a thread for replies to room
         # Auto-thread: by default, Chatto creates a thread for replies to room
         # messages (not DMs, not already in a thread). This keeps conversations
         # messages (not DMs, not already in a thread). This keeps conversations
         # organized in the room. Can be disabled via extra.auto_thread=false.
         # organized in the room. Can be disabled via extra.auto_thread=false.
-        self._auto_thread = utils.is_truthy_value(os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()) # todo: add config.yaml support
+        self._auto_thread = _get_env_or_extra_truthy("CHATTO_AUTO_THREAD", pconfig.extra.get("auto_thread"))
+
+
+    def _add_env_to_seed(self, seed: dict, our_key: str) -> dict
+        env_value = os.getenv("CHATTO_" + our_key.upper())
+        if env_value:
+            seed[our_key.lower()] = env_value
+        return seed
+
+    def env_only_to_seed_extra(self) -> Optional[dict]:
+        """Seed PlatformConfig.extra from env vars.
+
+        Returns a dict compatible with the PlatformConfig merge hook (or None
+        when no env-provided values are present).
+
+        Called by the platform registry during load_gateway_config().
+        Return None when the platform isn't minimally configured — the
+        caller then skips auto-enabling. Return a dict to seed extras.
+
+        The special 'home_channel' key is extracted and becomes a proper
+        HomeChannel dataclass on the PlatformConfig; every other key is
+        merged into PlatformConfig.extra.
+        """
+        seed = {}
+        seed["base_url"] = (os.getenv("CHATTO_BASE_URL") or ChattoClient.DEFAULT_BASE_URL).strip()
+
+        seed = self._add_env_to_seed(seed, "TOKEN")
+        seed = self._add_env_to_seed(seed, "LOGIN")
+        seed = self._add_env_to_seed(seed, "PASSWORD")
+
+        seed = self._add_env_to_seed(seed, "CHANNELS")
+        seed = self._add_env_to_seed(seed, "HOME_CHANNEL")
+        seed = self._add_env_to_seed(seed, "REQUIRE_MENTION")
+        seed = self._add_env_to_seed(seed, "FREE_RESPONSE_CHANNELS")
+        seed = self._add_env_to_seed(seed, "AUTO_THREAD")
+
+        return seed
+
+
+    def _config_key_not_set(self, config_key: str) -> ValueError:
+        envname_upper: str = "CHATTO_" + config_key.upper()
+        keyname_lower: str = "chatto.extra." + config_key.lower()
 
 
+        valueErrorString: str = "Environment variable " + envname_upper + " or config.yaml YAML key " + keyname_lower + " is not set."
+        return ValueError(valueErrorString)
 
 
+    
+    def _ensure_configuration(self) -> None:
+        """Ensure that the Chatto configuration is valid."""
+        if not self._base_url:
+            raise self._config_key_not_set("base_url")
 
 
+        if not self._login:
+            raise self._config_key_not_set("login")
 
 
+        if not self._password:
+            raise self._config_key_not_set("password")
+        
+        return None

+ 8 - 8
plugin.yaml

@@ -1,19 +1,19 @@
-name: chatto-platform
+name: chatto
 label: Chatto
 label: Chatto
 kind: platform
 kind: platform
 version: 1.0.0
 version: 1.0.0
 description: >
 description: >
   Chatto gateway adapter for Hermes Agent.
   Chatto gateway adapter for Hermes Agent.
-  Connects to a Chatto server (self-hosted team chat) and relays messages
-  between rooms/DMs and the Hermes agent.  Uses the Chatto ConnectRPC API
+  Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages
+  between rooms/DMs and the Hermes agent. Uses the Chatto ConnectRPC API
   (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
   (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
   protocol (binary protobuf) for inbound events.  No Python packages
   protocol (binary protobuf) for inbound events.  No Python packages
-  required beyond websockets — pure stdlib protobuf codec.
-author: Nous Research
+  required beyond websockets.
+author: Chatto community guys
 requires_env:
 requires_env:
-  - name: CHATTO_URL
-    description: "Base URL of the Chatto server (e.g. https://chat.example.com)"
-    prompt: "Chatto server URL"
+  - 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)"
+    prompt: "Chatto server Base URL"
     password: false
     password: false
   - name: CHATTO_LOGIN
   - name: CHATTO_LOGIN
     description: "Chatto login (username)"
     description: "Chatto login (username)"

+ 7 - 4
pyproject.toml

@@ -12,15 +12,18 @@ license = {text = "MIT"}
 authors = [
 authors = [
     {name = "liv", email = "paul@klumpp.de"},
     {name = "liv", email = "paul@klumpp.de"},
 ]
 ]
+dependencies = [
+]
 
 
-[project.entry-points."hermes_agent.plugins"]
-hermes-chatto-plugin = "hermes-chatto-plugin"
+#[project.entry-points."hermes_agent.plugins"]
+#hermes-chatto-plugin = "hermes-chatto-plugin"
 
 
 [dependency-groups]
 [dependency-groups]
 dev = [
 dev = [
-    "pytest>=8.0",
+    "pytest>=9.0",
     "pytest-asyncio>=0.24",
     "pytest-asyncio>=0.24",
-    "chattolib[realtime]>=0.4.19"
+    "chattolib[realtime]>=0.4.19",
+    "connectrpc>=0.11.1",
 ]
 ]
 
 
 [tool.setuptools.packages.find]
 [tool.setuptools.packages.find]

+ 2 - 1
test_adapter.py

@@ -25,6 +25,8 @@ import pytest_asyncio
 # Import chattolib types for tests - using vendored chattolib from adapter
 # Import chattolib types for tests - using vendored chattolib from adapter
 
 
 # -- Path setup --
 # -- Path setup --
+PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
+sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, "/opt/hermes")
 sys.path.insert(0, "/opt/hermes")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 
@@ -37,7 +39,6 @@ from adapter import (
     validate_config,
     validate_config,
     register,
     register,
 )
 )
-
 from gateway.config import PlatformConfig
 from gateway.config import PlatformConfig
 from gateway.platforms.base import SendResult, MessageEvent, MessageType
 from gateway.platforms.base import SendResult, MessageEvent, MessageType