Selaa lähdekoodia

courtesy commit

Paul Klumpp 3 päivää sitten
vanhempi
commit
fa5125310c
7 muutettua tiedostoa jossa 340 lisäystä ja 319 poistoa
  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 mimetypes
 import os
-import threading
 from collections import OrderedDict
 from datetime import datetime, timezone
+import time
 from typing import Any, Dict, List, Optional, Tuple, cast
 from urllib.parse import urlsplit, urlunsplit
 
-from plugins.plugin_utils import lazy_singleton
 import utils
 
 logger = logging.getLogger(__name__)
@@ -64,26 +63,19 @@ try:
     # Try vendored chattolib first
     from vendor.chattolib.client import (
         ChattoClient,
-        ChattoError,
-        ChattoAuthError,
     )
     from vendor.chattolib.exceptions import (
-        ChattoConnectError,
+        ChattoAuthError,
+        ChattoError,
     )
     from vendor.chattolib.realtime import (
         ChattoRealtimeError,
         ChattoRealtimeCloseError,
-        RealtimeConnection,
         RealtimeEvent,
-        ServerHello,
         stream_events,
     )
     from vendor.chattolib.types import (
-        RoomKind,
         PresenceStatus,
-        RoomWithViewerState,
-        User,
-        Message,
     )
     from vendor.chattolib._pb.chatto.api.v1 import (
         rooms_pb2 as room_service_pb2,
@@ -95,33 +87,10 @@ except ImportError as 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
@@ -212,15 +181,14 @@ class ChattoAdapter(BasePlatformAdapter):
     MAX_MESSAGE_LENGTH = 10000
     _SPLIT_THRESHOLD = 9900
     splits_long_messages = True
+    supports_code_blocks: bool = True
 
-    _chatto_client: ChattoClient
-    
     def __init__(self, config: PlatformConfig, **kwargs):
         """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 ---
         self.chatto_config = chatto_config
@@ -255,37 +223,44 @@ class ChattoAdapter(BasePlatformAdapter):
         # Member directory cache: user_id -> user info 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
     # ------------------------------------------------------------------ #
 
     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:
             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,
+                    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:
         """Return a ChattoClient or raise RuntimeError if unavailable.
@@ -299,21 +274,13 @@ class ChattoAdapter(BasePlatformAdapter):
             raise RuntimeError("Chatto client unavailable")
         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:
-        """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
-        
+
         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:
         """Force re-login (token expired)."""
@@ -326,10 +293,10 @@ class ChattoAdapter(BasePlatformAdapter):
     # ------------------------------------------------------------------ #
     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:
         """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)
     
     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():
             return False
 
@@ -383,7 +316,7 @@ class ChattoAdapter(BasePlatformAdapter):
         except RuntimeError:
             self._set_fatal_error("connect_failed", "Chatto client not available", retryable=True)
             return False
-        
+
         # Get our own user info
         try:
             me = await client.me()
@@ -421,6 +354,7 @@ class ChattoAdapter(BasePlatformAdapter):
         except Exception as e:
             logger.error("Chatto: failed to list rooms: %s", e)
             return False
+
         all_room_ids = []
         for entry in rooms:
             room = entry.get("room", {})
@@ -434,15 +368,15 @@ class ChattoAdapter(BasePlatformAdapter):
             viewer = entry.get("viewerState", {})
             is_member = viewer.get("isMember", False)
             # 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)
                 all_room_ids.append(rid)
             elif is_member:
                 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:
             watch = all_room_ids
 
@@ -474,7 +408,7 @@ class ChattoAdapter(BasePlatformAdapter):
         self._start_liveness_probe()
         logger.info(
             "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,
             len(watch),
         )
@@ -514,7 +448,6 @@ class ChattoAdapter(BasePlatformAdapter):
             self._ws_task = None
         self._token = None
         self._chatto_client = None
-        self._client_slot.reset()
 
     # ------------------------------------------------------------------ #
     # Liveness probe
@@ -667,7 +600,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
     def _websocket_url(self) -> str:
         """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)
         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}")
@@ -709,83 +642,83 @@ class ChattoAdapter(BasePlatformAdapter):
         # Ensure ws_ready is available for synchronization with starter
         assert self._ws_ready is not None
         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
-                    logger.warning("Chatto: event stream error: %s, retrying in %.1fs", e, backoff)
                     await asyncio.sleep(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:
         """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:
             # 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
 
         # 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}"
         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)
@@ -2029,12 +1949,9 @@ class ChattoAdapter(BasePlatformAdapter):
                         self._user_cache[uid] = user_dict
                         users.append(user_dict)
             return users
-        except ChattoError as e:
+        except (ChattoError, Exception) as e:
             logger.debug("Chatto: ListUsers failed: %s", e)
             return []
-        except Exception as e:
-            logger.debug("Chatto: ListUsers error: %s", e)
-            return []
 
     async def get_user(self, user_id: str) -> Optional[dict]:
         """Get a single user by ID via UserService/GetUser.
@@ -2223,9 +2140,7 @@ def check_requirements() -> bool:
  
     # Then check configuration
     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
 
 
-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(
     pconfig,
     chat_id,
@@ -2326,7 +2213,7 @@ async def _standalone_send(
     """
     pconfig.getattr("extra", {})
 
-    chatto_config = ChattoConfig(extra=pconfig.extra)
+    chatto_config = ChattoConfiguration(pconfig=pconfig)
 
     # Create a temporary client for standalone sending
     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):
         return SendResult(success=False, error="Chatto: base URL or credentials missing")
     
-    try:
+    try: 
+        chatto_config._ensure_configuration()
         if token:
             client = ChattoClient(base_url=chatto_config._base_url, token=token)
         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}")
+    finally:
+        logger.debug("Chatto standalone client: {client}")
 
     try:
         kwargs: Dict[str, Any] = {}
@@ -2361,10 +2252,8 @@ async def _standalone_send(
     finally:
         try:
             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:
@@ -2384,9 +2273,9 @@ def interactive_setup() -> None:
         def set_env_var(k: str, v: str) -> None:
             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:
-        set_env_var("CHATTO_URL", url)
+        set_env_var("CHATTO_BASE_URL", url)
     login = prompt_env("Chatto login (username):")
     if login:
         set_env_var("CHATTO_LOGIN", login)
@@ -2408,22 +2297,21 @@ def interactive_setup() -> None:
 def register(ctx) -> None:
     """Plugin entry point — called by the Hermes plugin system."""
     ctx.register_platform(
-        name="chatto",
-        label="Chatto Chat Server",
+        name=ChattoConstants.PLATFORM_NAME,
+        label=ChattoConstants.PLATFORM_LABEL,
         adapter_factory=lambda cfg: ChattoAdapter(cfg),
         check_fn=check_requirements,
         validate_config=validate_config,
         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",
-        env_enablement_fn=_env_enablement,
+        env_enablement_fn=ChattoConfiguration.env_only_to_seed_extra,
         setup_fn=interactive_setup,
         apply_yaml_config_fn=_apply_yaml_config,
         cron_deliver_env_var="CHATTO_HOME_CHANNEL",
         standalone_sender_fn=_standalone_send,
         allowed_users_env="CHATTO_ALLOWED_USERS",
         allow_all_env="CHATTO_ALLOW_ALL_USERS",
-        auto_thread_env="CHATTO_AUTO_THREAD",
         max_message_length=_MAX_MESSAGE_LENGTH,
         emoji="💬",
         allow_update_command=True,

+ 14 - 12
after-install.md

@@ -1,29 +1,31 @@
 ## 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_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
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE
    ```
 
-4. **Restart the gateway**:
+3. Restart the Hermes Gateway
    ```bash
    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 email.policy import default
 import os
-from typing import Optional
+from typing import Any, Optional
 
+from gateway.config import PlatformConfig
 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
-class ChattoConfig:
+class ChattoConfiguration:
     """
     Chatto Platform Config
 
@@ -21,50 +70,89 @@ class ChattoConfig:
     """
 
     _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 = 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
         # messages (not DMs, not already in a thread). This keeps conversations
         # 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
 kind: platform
 version: 1.0.0
 description: >
   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
   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:
-  - 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
   - name: CHATTO_LOGIN
     description: "Chatto login (username)"

+ 7 - 4
pyproject.toml

@@ -12,15 +12,18 @@ license = {text = "MIT"}
 authors = [
     {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]
 dev = [
-    "pytest>=8.0",
+    "pytest>=9.0",
     "pytest-asyncio>=0.24",
-    "chattolib[realtime]>=0.4.19"
+    "chattolib[realtime]>=0.4.19",
+    "connectrpc>=0.11.1",
 ]
 
 [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
 
 # -- 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, "/root/.hermes/plugins/platforms/chatto")
 
@@ -37,7 +39,6 @@ from adapter import (
     validate_config,
     register,
 )
-
 from gateway.config import PlatformConfig
 from gateway.platforms.base import SendResult, MessageEvent, MessageType