Explorar o código

Run off the vendored tree and complete the hermes_* hook surface

adapter.py puts its own vendor/ directory at the front of sys.path and
imports chattolib package-relative (.vendor.*), so the shipped tree
wins over any host copy. The gateway-facing surface is completed:
hermes_adapter_factory, hermes_validate_config, hermes_setup_fn,
hermes_env_enablement_fn join the existing hooks, and register() moves
back into adapter.py, seeding declared env vars into
PlatformConfig.extra.
Paul Klumpp hai 1 semana
pai
achega
9e4bf7315c
Modificáronse 3 ficheiros con 303 adicións e 243 borrados
  1. 1 1
      __init__.py
  2. 270 28
      adapter.py
  3. 32 214
      platform_config.py

+ 1 - 1
__init__.py

@@ -1,4 +1,4 @@
-from .platform_config import register
+from .adapter import register
 
 __all__ = ["register"]
 

+ 270 - 28
adapter.py

@@ -12,6 +12,20 @@ including both outbound messaging and realtime WebSocket connections.
 
 from __future__ import annotations
 
+import dataclasses
+import json
+import sys
+import os
+from pathlib import Path
+
+# 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
+current_dir = Path(__file__).parent
+vendor_dir = current_dir / "vendor"
+
+# 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
+if str(vendor_dir) not in sys.path:
+    sys.path.insert(0, str(vendor_dir))
+
 import asyncio
 import hashlib
 import logging
@@ -40,27 +54,27 @@ from gateway.config import Platform, PlatformConfig
 
 try:
     # Try vendored chattolib first
-    from vendor.chattolib.client import (
+    from .vendor.chattolib.client import (
         ChattoClient,
     )
-    from vendor.chattolib.exceptions import (
+    from .vendor.chattolib.exceptions import (
         ChattoAuthError,
         ChattoError,
     )
-    from vendor.chattolib.realtime import (
+    from .vendor.chattolib.realtime import (
         ChattoRealtimeError,
         ChattoRealtimeCloseError,
         RealtimeEvent,
         stream_events,
     )
-    from vendor.chattolib.types import (
+    from .vendor.chattolib.types import (
         PresenceStatus,
     )
-    from vendor.chattolib._pb.chatto.api.v1 import (
+    from .vendor.chattolib._pb.chatto.api.v1 import (
         rooms_pb2 as room_service_pb2,
         threads_pb2 as thread_service_pb2
     )
-    from vendor.chattolib._transport import pb_to_dict
+    from .vendor.chattolib._transport import pb_to_dict
 
 except ImportError as e:
     logger.error("Chatto: failed to import vendored chattolib: %s", e)
@@ -110,6 +124,11 @@ def _decode_event_envelope(data: bytes) -> dict:
 # Adapter
 # --------------------------------------------------------------------------- #
 
+def hermes_adapter_factory(config: PlatformConfig):
+    """Factory wrapper that constructs ChattoAdapter from a PlatformConfig."""
+    return ChattoAdapter(config)
+
+
 class ChattoAdapter(BasePlatformAdapter):
     """Chatto platform adapter — receives messages via WebSocket realtime,
     sends via ConnectRPC REST."""
@@ -121,11 +140,16 @@ class ChattoAdapter(BasePlatformAdapter):
 
     def __init__(self, pconfig: PlatformConfig, **kwargs):
         """Signature needs to be compatible with BasePlatformAdapter.__init__ """
-        super().__init__(config=pconfig, platform=Platform(ChattoConstants.PLATFORM_ID))
+        super().__init__(config=pconfig, platform=Platform(ChattoConstants.PLATFORM_NAME))
         # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values insode PlatformConfig.
 
         # --- Configuration from our configuration data class with some logic ---
         self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
+        logger.info("Chatto: Configured: %s", json.dumps(dataclasses.asdict(self.chatto_config), indent=2) )
+        from pprint import pprint
+
+        # Gibt die Attribute der Instanz hübsch formatiert als Dictionary aus
+        pprint(vars(self.chatto_config), indent=2)
 
         # ------ State -------
         # SDK runtime handle (injected by Hermes); annotate for Pylance
@@ -180,7 +204,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
                 client = await self._open_client(
                     base_url=self.chatto_config.base_url.value,
-                    login=self.chatto_config.base_url.value,
+                    login=self.chatto_config.login.value,
                     password=self.chatto_config.password.value,
                     token=self.chatto_config.token.value,
                 )
@@ -209,7 +233,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
     async def _ensure_token(self) -> bool:
         """Ensure we have a logged-in Chatto client and token."""
-        if self.chatto_config.token.value and self._chatto_client is not None:
+        if self.chatto_config.token.value and isinstance(self._chatto_client, ChattoClient):
             return True
 
         client = await self._get_chatto_client()
@@ -225,6 +249,7 @@ class ChattoAdapter(BasePlatformAdapter):
     # Connection
     # ------------------------------------------------------------------ #
     async def _open_client(
+        self,
         *,
         base_url: str,
         login: str,
@@ -239,6 +264,7 @@ class ChattoAdapter(BasePlatformAdapter):
     
     async def connect(self, *, is_reconnect: bool = False) -> bool:
         """Connect to Chatto and start the realtime event stream."""
+        logger.info("Chatto: connecting...")
 
         if not await self._ensure_token():
             return False
@@ -252,10 +278,11 @@ class ChattoAdapter(BasePlatformAdapter):
         # Get our own user info
         try:
             me = await client.me()
+            self.me = me
             self._user_id = me.id
             self._user_login = me.login
             self._user_display = me.display_name or ""
-            logger.info("Chatto: got user info: %s", self._user_login)
+            logger.info("Chatto: got user info: %s from %s", self._user_login, self.me)
         except Exception as e:
             logger.error("Chatto: failed to get user info: %s", e)
             return False
@@ -282,7 +309,7 @@ class ChattoAdapter(BasePlatformAdapter):
                     }
                 }
                 rooms.append(entry)
-            logger.info("Chatto: got %d rooms", len(rooms))
+            logger.info("Chatto: got %d rooms: %s", len(rooms), rooms)
         except Exception as e:
             logger.error("Chatto: failed to list rooms: %s", e)
             return False
@@ -308,7 +335,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 all_room_ids.append(rid)
 
         if self.chatto_config.channels_list.value:
-            watch = list(self.chatto_config.channels_list.value)
+            watch = self.chatto_config.channels_list.value
         else:
             watch = all_room_ids
 
@@ -322,8 +349,8 @@ class ChattoAdapter(BasePlatformAdapter):
                 await self._join_room(rid)
 
         # Pick home channel
-        if not self._home_channel:
-            self._home_channel = watch[0]
+        if not self.chatto_config.home_channel.value:
+            self.chatto_config.home_channel.value = watch[0]
 
         self._watch_room_ids = watch
 
@@ -531,22 +558,13 @@ class ChattoAdapter(BasePlatformAdapter):
     async def _start_chattolib_realtime(self) -> bool:
         """Start realtime connection using chattolib's stream_events."""
         self._ws_ready = asyncio.Event()
-        self._ws_task = asyncio.create_task(self._chattolib_event_loop())
         
-        try:
-            await asyncio.wait_for(self._ws_ready.wait(), timeout=ChattoConstants.WS_AUTH_TIMEOUT + 10)
-        except (asyncio.TimeoutError, TimeoutError):
-            logger.warning("Chatto: chattolib realtime did not connect in time")
-            self._ws_active = False
-            if self._ws_task and not self._ws_task.done():
-                self._ws_task.cancel()
-                try:
-                    await self._ws_task
-                except asyncio.CancelledError:
-                    pass
-            self._ws_task = None
-            return False
+        # Stream als Hintergrund-Task starten
+        self._ws_task = asyncio.create_task(self._chattolib_event_loop())
         
+        # Direkt als erfolgreich markieren und dem Gateway die Kontrolle zurückgeben,
+        # anstatt auf das Event zu warten.
+        self._ws_active = True 
         return True
 
     async def _chattolib_event_loop(self) -> None:
@@ -945,6 +963,7 @@ class ChattoAdapter(BasePlatformAdapter):
                     pass
         else:
             logger.debug("Chatto WS: unknown transient event type: %s", event_type)
+
     async def _fetch_and_dispatch_event(self, room_id: str, event_id: str, thread_root_event_id: str = "") -> None:
         """Fetch a single event by ID via REST and dispatch it.
 
@@ -962,6 +981,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 logger.warning("Chatto WS: REST fallback fetch aborted - no client available for event %s", event_id)
                 return
 
+            logger.info("wat 1")
             # Fetch events either from the thread timeline or the room timeline
             if thread_root_event_id:
                 resp = await client.services.threads.get_thread_events(
@@ -979,6 +999,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
             data = pb_to_dict(resp)
             events = data.get("page", {}).get("events", [])
+            logger.info("wat 2")
             for ev in events:
                 ev_id = str(ev.get("id", ""))
                 if ev_id == event_id:
@@ -1008,16 +1029,21 @@ class ChattoAdapter(BasePlatformAdapter):
         message dict (decoded from protobuf) and dispatches it through the
         standard Hermes message pipeline.
         """
+        logger.info("Dispatching 1")
+
         if not self._message_handler:
+            logger.info("Dispatching 2")
             return
 
         actor_id = str(msg.get("actorId", ""))
         # Skip our own messages
         if actor_id == self._user_id:
+            logger.info("Dispatching 3")
             return
 
         # Best-effort: cache the sender's display name for richer message context
         if actor_id and actor_id not in self._user_cache:
+            logger.info("Dispatching 4")
             try:
                 await self.get_user(actor_id)
             except Exception:
@@ -1025,6 +1051,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
         body = str(msg.get("body", ""))
         if not body:
+            logger.info("Dispatching 5")
             return
 
         msg_id = str(msg.get("id", ""))
@@ -1034,13 +1061,17 @@ class ChattoAdapter(BasePlatformAdapter):
         is_dm = chat_type == "dm"
         mentioned = False
         if self._user_login:
+            logger.info("Dispatching 6")
             mentioned = f"@{self._user_login}" in body
         if self._user_display:
+            logger.info("Dispatching 7")
             mentioned = mentioned or f"@{self._user_display}" in body
 
         if self.chatto_config.require_mention.value and not is_dm and not mentioned:
+            logger.info("Dispatching 8")
             # Allow free-response rooms (like Discord's free_response_channels)
             if room_id not in self.chatto_config.free_response_channels_list.value:
+                logger.info("Dispatching 9")
                 return
 
         # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
@@ -1059,6 +1090,7 @@ class ChattoAdapter(BasePlatformAdapter):
         thread_id = None
         thread_info = msg.get("thread", {})
         if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
+            logger.info("Dispatching 10")
             thread_id = str(thread_info.get("threadRootEventId", ""))
             # Hermes SDK: Propagate thread context if thread_id is set
             try:
@@ -1085,6 +1117,7 @@ class ChattoAdapter(BasePlatformAdapter):
         except (ValueError, TypeError):
             timestamp = datetime.now()
 
+        logger.info("Dispatching MessageEvent to Hermes")
         event = MessageEvent(
             text=text,
             message_type=MessageType.TEXT,
@@ -2086,3 +2119,212 @@ async def hermes_standalone_sender_fn(
             await client.close()
         except Exception as exc:
             logger.error("Chatto standalone: error closing short-lived client. Perhaps already closed. {exc}")
+
+
+def hermes_validate_config(config: PlatformConfig) -> bool:
+    """"
+    Function name should be the same as register argument name with "hermes_" prefix, so we
+    know that it is needed for plugin register(). Do not change signature.
+    - config
+    Check whether Chatto Plugin is configured. Compare to hermes_is_connected()."""
+
+    chatto_config = ChattoConfiguration(pconfig=config)
+
+    if len(chatto_config.allowed_users.value) > 0 and chatto_config.allow_all_users.value:
+        logger.info("Chatto: Conflicting configuration. Either use 'allowed_users' or 'allow_all_users' but not both.")
+        return False
+
+    if chatto_config.base_url.value:
+        if chatto_config.token.value or bool(chatto_config.login.value and chatto_config.password.value):
+            return True
+        else:
+            logger.error("Chatto: Minimally, either token or login/password must be set.")
+    else:
+        logger.error("Chatto: base_url must be set.")
+
+    return False
+
+
+def hermes_check_fn() -> bool:
+    """Check if Chatto is configured and dependencies are available.
+    Add real logic?! Or just .. there are no dependencies.. always return true. Really. Docs suck."""
+    try:
+        from .vendor.chattolib.client import ChattoClient
+        return True
+    except ImportError:
+        return False
+    return True
+
+
+# ---------------------------------------------------------------------------
+# is_connected probe
+# ---------------------------------------------------------------------------
+
+def hermes_is_connected(config: PlatformConfig) -> bool:
+    """Check whether Chatto Plugin is connected. But where to? To the Hermes Agent? To Chatto Server?
+    The Hermes Agent plugin docs suck and it seems there are many functions to do the same."""
+    return bool(hermes_validate_config(config) and config.enabled)
+
+
+
+# ---------------------------------------------------------------------------
+# YAML → env config bridge
+# ---------------------------------------------------------------------------
+
+@DeprecationWarning
+def hermes_apply_yaml_config_fn(yaml_dict: dict, platform_dict: dict) -> Optional[dict]:
+    """Translate config.yaml chatto.extra keys into CHATTO_* env vars.
+    I don't actually get why Hermes wants us to modify OS environment variables.
+    Bad behavior in my book.
+    
+    Also .. I don't think we need this"""
+
+    if not isinstance(platform_dict, dict):
+        platform_dict = {}
+    extra = platform_dict.get("extra", {}) or {}
+    if not isinstance(extra, dict):
+        extra = {}
+
+    for yaml_key, env_key in ChattoConstants.EXTRA_ENV_MAPPING.items():
+        val = extra.get(yaml_key)
+        if val is not None and not os.getenv(env_key):
+            if isinstance(val, bool):
+                env_val = str(val).lower()
+            elif isinstance(val, list):
+                env_val = ",".join(str(v) for v in val)
+            else:
+                env_val = str(val)
+            os.environ[env_key] = env_val
+
+    channels = extra.get(ChattoConfiguration.channels.field_name)
+    if isinstance(channels, list) and not os.getenv(ChattoConfiguration.channels.env_name):
+        os.environ[ChattoConfiguration.channels.env_name] = ",".join(str(c) for c in channels)
+
+    allowed = extra.get(ChattoConfiguration.allowed_users.field_name)
+    if isinstance(allowed, list) and not os.getenv(ChattoConfiguration.allowed_users.env_name):
+        os.environ[ChattoConfiguration.allowed_users.env_name] = ",".join(str(u) for u in allowed)
+
+    if ChattoConfiguration.allow_all_users.field_name in extra and not os.getenv(ChattoConfiguration.allow_all_users.env_name):
+        os.environ[ChattoConfiguration.allow_all_users.env_name] = str(extra[ChattoConfiguration.allow_all_users.field_name]).lower()
+
+    return None
+
+
+def hermes_setup_fn() -> None:
+    """Interactive setup wizard for Chatto. Is called by and only works in Hermes CLI context.
+    Function name should be the same as register argument name with "hermes_" prefix, so we
+    know that it is needed for plugin register().
+    """
+    from hermes_cli.setup import (
+        prompt,
+        prompt_yes_no,
+        save_env_value,
+        get_env_value,
+        print_header,
+        print_info,
+        print_warning,
+        print_success,
+    )
+
+    url = prompt(
+        "Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
+    if url:
+        save_env_value(ChattoConfiguration.base_url.env_name, url)
+
+    login = prompt("Chatto login (username):")
+    if login:
+        save_env_value(ChattoConfiguration.login.env_name, login)
+
+    password = prompt("Chatto password:", password=True)
+    if password:
+        save_env_value(ChattoConfiguration.password.env_name, password)
+
+    channels = prompt("Room IDs to watch (comma-separated, or empty for all):")
+    if channels:
+        save_env_value(ChattoConfiguration.channels.env_name, channels)
+
+    home = prompt("Home room ID for notifications (or empty):")
+    if home:
+        save_env_value(ChattoConfiguration.home_channel.env_name, home)
+
+    allow_all = prompt_yes_no("Allow all users to talk? (true/false):")
+    if allow_all:
+        save_env_value(ChattoConfiguration.allow_all_users.env_name, str(allow_all))
+    print_success("\n✓ Chatto configured. Restart the gateway to activate.")
+
+
+def hermes_env_enablement_fn() -> 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.
+
+    Function name should be the same as register argument name with "hermes_" prefix, so we
+    know that it is needed for plugin register().
+    """
+
+    def _add_env_to_seed(seed: dict, our_key: str) -> dict:
+        env_value = os.getenv(our_key.upper())
+        if env_value:
+            seed[our_key.lower()] = env_value
+        return seed
+    
+    seed = {}
+    seed["base_url"] = (os.getenv(ChattoConfiguration.base_url.env_name) or ChattoClient.DEFAULT_BASE_URL).strip()
+
+    seed = _add_env_to_seed(seed, ChattoConfiguration.token.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.login.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.password.env_name)
+
+    seed = _add_env_to_seed(seed, ChattoConfiguration.channels.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.home_channel.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.require_mention.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.free_response_channels_list.env_name)
+    seed = _add_env_to_seed(seed, ChattoConfiguration.auto_thread.env_name)
+
+    logger.info("seed: " + str(seed))
+
+    return seed
+
+
+# ---------------------------------------------------------------------------
+# Plugin registration entry point
+# ---------------------------------------------------------------------------
+
+def register(ctx) -> None:
+    """Plugin entry point — called by the Hermes plugin system."""
+    logger.info("Registering Chatto platform plugin on Hermes Agent")
+    ctx.register_platform(
+        name=ChattoConstants.PLATFORM_NAME,
+        label=ChattoConstants.PLATFORM_LABEL,
+        adapter_factory=hermes_adapter_factory,
+        check_fn=hermes_check_fn,
+        validate_config=hermes_validate_config,
+        is_connected=hermes_is_connected,
+        install_hint=ChattoConstants.INSTALL_HINT,
+        env_enablement_fn=hermes_env_enablement_fn,
+        setup_fn=hermes_setup_fn,
+        apply_yaml_config_fn=hermes_apply_yaml_config_fn,
+        cron_deliver_env_var=ChattoConfiguration.home_channel.env_name,
+        standalone_sender_fn=hermes_standalone_sender_fn,
+        allowed_users_env=ChattoConfiguration.allowed_users.env_name,
+        allow_all_env=ChattoConfiguration.allow_all_users.env_name,
+        max_message_length=ChattoConstants.MAX_MESSAGE_LENGTH,
+        emoji="💬",
+        allow_update_command=True,
+        pii_safe=False,
+        platform_hint=(
+            "You are chatting in Chatto (a self-hosted or cloud-hosted team or community chat server). "
+            "Markdown is supported. Users _may_ address you by @-mentioning your name. If configured, " \
+            "you also react without a @-mention. Direct messages reach you without a mention."
+            "Keep responses conversational."
+        ),
+    )

+ 32 - 214
platform_config.py

@@ -3,17 +3,30 @@ Chatto Platform Config
 
 
 """
+
+import dataclasses
+import json
+import sys
+import os
+from pathlib import Path
+
+# 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
+current_dir = Path(__file__).parent
+vendor_dir = current_dir / "vendor"
+
+# 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
+if str(vendor_dir) not in sys.path:
+    sys.path.insert(0, str(vendor_dir))
+
 from dataclasses import dataclass
 import logging
 import os
-from typing import Any, Dict, Optional, cast
+from typing import Any, Dict, Optional
 
 from gateway.config import PlatformConfig
 import utils
 
-from vendor.chattolib.client import ChattoClient
-
-from .adapter import ChattoAdapter, hermes_standalone_sender_fn
+from .vendor.chattolib.client import ChattoClient
 
 logger = logging.getLogger(__name__)
 
@@ -29,7 +42,6 @@ class ChattoConstants:
     def __init__(self):
         pass
 
-    PLATFORM_ID: str = "chatto"
     PLATFORM_NAME: str = "chatto-platform"
     PLATFORM_LABEL: str = "Chatto"
 
@@ -94,9 +106,13 @@ def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str], default: O
         return env_value.strip()
 
     if extra_val is not None:
-        return extra_val.strip()
+        if isinstance(extra_val, str):
+            return extra_val.strip()
+        else:
+            logger.error("extra_val: " + extra_val + " is supposed to be str, but " + str(type(extra_val)) + " was found.")
 
     if default is not None:
+        logger.info("Chatto: Defaulting to '%s'", default)
         return default.strip() 
     return None
 
@@ -114,19 +130,22 @@ def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str], default: bo
 
 
 def _split_str_to_list(mystring: str) -> list:
-    return list(c.strip() for c in mystring.split(","))
+    logger.info("mystring: " + mystring)
+    return list(c for c in mystring.split(","))
 
 
-def _get_env_or_extra_list(env_var: str, extra_val: Optional[set]) -> list[str]:
+def _get_env_or_extra_list(env_var: str, extra_val: Optional[list[str]]) -> 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:
+        #logger.info("extra_val is1: " + str(type(extra_val)))
+        #logger.info("extra_val is2: " + str(extra_val))
         if isinstance(extra_val, list):
             return list(
-                c.strip() for c in extra_val
+                c for c in extra_val
             )
         if isinstance(extra_val, str):
             return _split_str_to_list(extra_val)
@@ -156,6 +175,7 @@ class ConfigField(Generic[T]):
     def __set__(self, instance: Any, value: T) -> None:
         self.value = value
         self._type = type(value)
+        logger.info("Chatto: configuration field '" + self.field_name + "' set to '" + str(value) + "'")
 
     def __str__(self) -> str:
         return str(self.value)
@@ -180,7 +200,7 @@ class ChattoConfiguration:
     channels = ConfigField(str)
     channels_list = ConfigField(list[str])
     home_channel = ConfigField(str)
-    allowed_users = ConfigField(str)
+    allowed_users = ConfigField(list[str])
     require_mention = ConfigField(bool)
     free_response_channels_list = ConfigField(list[str])
     auto_thread = ConfigField(bool)
@@ -205,7 +225,7 @@ class ChattoConfiguration:
 
         self.home_channel.value = _get_env_or_extra_str(self.home_channel.env_name, pconfig.extra.get(self.home_channel.field_name))
 
-        self.allowed_users.value = _get_env_or_extra_str(self.allowed_users.env_name, pconfig.extra.get(self.allowed_users.field_name))
+        self.allowed_users.value = _get_env_or_extra_list(self.allowed_users.env_name, pconfig.extra.get(self.allowed_users.field_name))
 
         self.require_mention.value = _get_env_or_extra_truthy(self.require_mention.env_name, pconfig.extra.get(self.require_mention.field_name))
 
@@ -219,206 +239,4 @@ class ChattoConfiguration:
         self.auto_thread.value = _get_env_or_extra_truthy(self.auto_thread.env_name, pconfig.extra.get(self.auto_thread.field_name))
 
         self.allow_all_users.value = _get_env_or_extra_truthy(self.allow_all_users.env_name, pconfig.extra.get(self.allow_all_users.field_name))
-        self.reactions.value = _get_env_or_extra_truthy(self.reactions.env_name, pconfig.extra.get(self.reactions.field_name))
-
-
-def hermes_validate_config_fn(config: PlatformConfig) -> bool:
-    """"
-    Function name should be the same as register argument name with "hermes_" prefix, so we
-    know that it is needed for plugin register(). Do not change signature.
-    - config
-    Check whether Chatto Plugin is configured."""
-
-    chatto_config = ChattoConfiguration(pconfig=config)
-
-    if chatto_config.base_url.value and (chatto_config.token.value or (chatto_config.login.value and chatto_config.password.value)):
-        return True
-    return False
-
-
-def hermes_check_fn() -> bool:
-    """Check if Chatto is configured and dependencies are available.
-    Add real logic?! Or just .. there are no dependencies.. always return true. Really. Docs suck."""
-    try:
-        from vendor.chattolib.client import ChattoClient
-        return True
-    except ImportError:
-        return False
-    return True
-
-
-# ---------------------------------------------------------------------------
-# is_connected probe
-# ---------------------------------------------------------------------------
-
-def hermes_is_connected(config: PlatformConfig) -> bool:
-    """Check whether Chatto Plugin is connected. But where to? To the Hermes Agent? To Chatto Server?
-    The Hermes Agent plugin docs suck and it seems there are many functions to do the same."""
-    return bool(hermes_validate_config_fn(config) and config.enabled)
-
-
-
-# ---------------------------------------------------------------------------
-# YAML → env config bridge
-# ---------------------------------------------------------------------------
-
-@DeprecationWarning
-def hermes_apply_yaml_config_fn(yaml_dict: dict, platform_dict: dict) -> Optional[dict]:
-    """Translate config.yaml chatto.extra keys into CHATTO_* env vars.
-    I don't actually get why Hermes wants us to modify OS environment variables.
-    Bad behavior in my book.
-    
-    Also .. I don't think we need this"""
-
-    if not isinstance(platform_dict, dict):
-        platform_dict = {}
-    extra = platform_dict.get("extra", {}) or {}
-    if not isinstance(extra, dict):
-        extra = {}
-
-    for yaml_key, env_key in ChattoConstants.EXTRA_ENV_MAPPING.items():
-        val = extra.get(yaml_key)
-        if val is not None and not os.getenv(env_key):
-            if isinstance(val, bool):
-                env_val = str(val).lower()
-            elif isinstance(val, list):
-                env_val = ",".join(str(v) for v in val)
-            else:
-                env_val = str(val)
-            os.environ[env_key] = env_val
-
-    channels = extra.get(ChattoConfiguration.channels.field_name)
-    if isinstance(channels, list) and not os.getenv(ChattoConfiguration.channels.env_name):
-        os.environ[ChattoConfiguration.channels.env_name] = ",".join(str(c) for c in channels)
-
-    allowed = extra.get(ChattoConfiguration.allowed_users.field_name)
-    if isinstance(allowed, list) and not os.getenv(ChattoConfiguration.allowed_users.env_name):
-        os.environ[ChattoConfiguration.allowed_users.env_name] = ",".join(str(u) for u in allowed)
-
-    if ChattoConfiguration.allow_all_users.field_name in extra and not os.getenv(ChattoConfiguration.allow_all_users.env_name):
-        os.environ[ChattoConfiguration.allow_all_users.env_name] = str(extra[ChattoConfiguration.allow_all_users.field_name]).lower()
-
-    return None
-
-
-def hermes_setup_fn() -> None:
-    """Interactive setup wizard for Chatto. Is called by and only works in Hermes CLI context.
-    Function name should be the same as register argument name with "hermes_" prefix, so we
-    know that it is needed for plugin register().
-    """
-    from hermes_cli.setup import (
-        prompt,
-        prompt_yes_no,
-        save_env_value,
-        get_env_value,
-        print_header,
-        print_info,
-        print_warning,
-        print_success,
-    )
-
-    url = prompt(
-        "Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
-    if url:
-        save_env_value(ChattoConfiguration.base_url.env_name, url)
-
-    login = prompt("Chatto login (username):")
-    if login:
-        save_env_value(ChattoConfiguration.login.env_name, login)
-
-    password = prompt("Chatto password:", password=True)
-    if password:
-        save_env_value(ChattoConfiguration.password.env_name, password)
-
-    channels = prompt("Room IDs to watch (comma-separated, or empty for all):")
-    if channels:
-        save_env_value(ChattoConfiguration.channels.env_name, channels)
-
-    home = prompt("Home room ID for notifications (or empty):")
-    if home:
-        save_env_value(ChattoConfiguration.home_channel.env_name, home)
-
-    allow_all = prompt_yes_no("Allow all users to talk? (true/false):")
-    if allow_all:
-        save_env_value(ChattoConfiguration.allow_all_users.env_name, str(allow_all))
-    print_success("\n✓ Chatto configured. Restart the gateway to activate.")
-
-
-def hermes_env_enablement_fn() -> 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.
-
-    Function name should be the same as register argument name with "hermes_" prefix, so we
-    know that it is needed for plugin register().
-    """
-
-    def _add_env_to_seed(seed: dict, our_key: str) -> dict:
-        env_value = os.getenv(our_key.upper())
-        if env_value:
-            seed[our_key.lower()] = env_value
-        return seed
-    
-    seed = {}
-    seed["base_url"] = (os.getenv(ChattoConfiguration.base_url.env_name) or ChattoClient.DEFAULT_BASE_URL).strip()
-
-    seed = _add_env_to_seed(seed, ChattoConfiguration.token.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.login.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.password.env_name)
-
-    seed = _add_env_to_seed(seed, ChattoConfiguration.channels.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.home_channel.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.require_mention.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.free_response_channels_list.env_name)
-    seed = _add_env_to_seed(seed, ChattoConfiguration.auto_thread.env_name)
-
-    return seed
-
-
-# ---------------------------------------------------------------------------
-# Plugin registration entry point
-# ---------------------------------------------------------------------------
-
-def hermes_adapter_factory(config: PlatformConfig):
-    """Factory wrapper that constructs ChattoAdapter from a PlatformConfig."""
-    return ChattoAdapter(config)
-
-
-def register(ctx) -> None:
-    """Plugin entry point — called by the Hermes plugin system."""
-    logger.error("Registering Chatto Plugin")
-    ctx.register_platform(
-        name=ChattoConstants.PLATFORM_NAME,
-        label=ChattoConstants.PLATFORM_LABEL,
-        adapter_factory=hermes_adapter_factory,
-        check_fn=hermes_check_fn,
-        validate_config=hermes_validate_config_fn,
-        is_connected=hermes_is_connected,
-        install_hint=ChattoConstants.INSTALL_HINT,
-        env_enablement_fn=hermes_env_enablement_fn,
-        setup_fn=hermes_setup_fn,
-        apply_yaml_config_fn=hermes_apply_yaml_config_fn,
-        cron_deliver_env_var=ChattoConfiguration.home_channel.env_name,
-        standalone_sender_fn=hermes_standalone_sender_fn,
-        allowed_users_env=ChattoConfiguration.allowed_users.env_name,
-        allow_all_env=ChattoConfiguration.allow_all_users.env_name,
-        max_message_length=ChattoConstants.MAX_MESSAGE_LENGTH,
-        emoji="💬",
-        allow_update_command=True,
-        pii_safe=False,
-        platform_hint=(
-            "You are chatting in Chatto (a self-hosted or cloud-hosted team or community chat server). "
-            "Markdown is supported. Users _may_ address you by @-mentioning your name. If configured, " \
-            "you also react without a @-mention. Direct messages reach you without a mention."
-            "Keep responses conversational."
-        ),
-    )
+        self.reactions.value = _get_env_or_extra_truthy(self.reactions.env_name, pconfig.extra.get(self.reactions.field_name))