Просмотр исходного кода

Make dispatch robust: room-kind cache, unified dedup, explicit config defaults

Realtime handling gets the guardrails that only showed up once it ran
against a live server. Room kinds are cached via get_room() on first
sight instead of relying on the connect-time scan, the two parallel
seen-lists collapse into one _seen list behind _is_seen(), and unknown
event kinds are skipped explicitly instead of falling through.

Config booleans (require_mention, auto_thread, reactions) carry their
real defaults through _get_env_or_extra_truthy() — previously a missing
value flipped to whatever str(None) parsed into. plugin.yaml prompts
for CHATTO_REACTIONS so the switch is discoverable at install time,
and tests pin the truthy parsing and the mention gate.
Paul Klumpp 2 недель назад
Родитель
Сommit
3e10f86bd8
5 измененных файлов с 201 добавлено и 123 удалено
  1. 130 99
      adapter.py
  2. 6 6
      platform_config.py
  3. 4 0
      plugin.yaml
  4. 45 18
      test_adapter.py
  5. 16 0
      test_platform_config.py

+ 130 - 99
adapter.py

@@ -13,6 +13,7 @@ including both outbound messaging and realtime WebSocket connections.
 from __future__ import annotations
 from __future__ import annotations
 
 
 import inspect
 import inspect
+import random
 import sys
 import sys
 import os
 import os
 from pathlib import Path
 from pathlib import Path
@@ -32,9 +33,8 @@ import hashlib
 import logging
 import logging
 import mimetypes
 import mimetypes
 import os
 import os
-from collections import OrderedDict
 from datetime import datetime, timezone
 from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional, Tuple, cast
+from typing import Any, Dict, List, Literal, Optional, Tuple, cast
 from urllib.parse import urlsplit
 from urllib.parse import urlsplit
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -63,7 +63,7 @@ try:
     )
     )
     from .vendor.chattolib.realtime import (
     from .vendor.chattolib.realtime import (
         ChattoRealtimeError,
         ChattoRealtimeError,
-        ChattoRealtimeCloseError,
+        ChattoRealtimeCloseError, RealtimeEvent,
         stream_events
         stream_events
     )
     )
     from .vendor.chattolib.realtime_types import (
     from .vendor.chattolib.realtime_types import (
@@ -117,11 +117,10 @@ class ChattoAdapter(BasePlatformAdapter):
         self._user_id: str = ""
         self._user_id: str = ""
         self._user_display: str = ""
         self._user_display: str = ""
         self._room_names: Dict[str, str] = {}
         self._room_names: Dict[str, str] = {}
-        self._room_kinds: Dict[str, str] = {}
+        self._room_kinds: Dict[str, RoomKind] = {}
         self._our_thread_roots: set = set()  # thread root event IDs we created
         self._our_thread_roots: set = set()  # thread root event IDs we created
         self._our_message_ids: set = set()  # message IDs we sent (for thread root detection)
         self._our_message_ids: set = set()  # message IDs we sent (for thread root detection)
-        self._seen: list[str] = []  # room_id -> OrderedDict(event_id -> None)
-        self._rt_events_seen: List[str] = []  # Plain RealtimeEvent-id list
+        self._seen: list[str] = []  # Plain RealtimeEvent-id list
         self._resume_cursor: Optional[str] = None
         self._resume_cursor: Optional[str] = None
         self._watch_room_ids: List[str] = []
         self._watch_room_ids: List[str] = []
         self._ws_task: Optional[asyncio.Task] = None
         self._ws_task: Optional[asyncio.Task] = None
@@ -390,13 +389,11 @@ class ChattoAdapter(BasePlatformAdapter):
             return
             return
 
 
         if message.actor_id in self._user_cache:
         if message.actor_id in self._user_cache:
+            # try the user cache.
             user = self._user_cache.get(message.actor_id)
             user = self._user_cache.get(message.actor_id)
         else: 
         else: 
             # get the user and update cache.
             # get the user and update cache.
-            try:
-                directory_member = await client.get_user(user_id=message.actor_id)
-            except Exception:
-                pass
+            directory_member = await client.get_user(user_id=message.actor_id)
             if directory_member is None:
             if directory_member is None:
                 return
                 return
             user = directory_member.user
             user = directory_member.user
@@ -412,29 +409,42 @@ class ChattoAdapter(BasePlatformAdapter):
         
         
         # 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.
         # Strip the mention from the text for the agent
         # Strip the mention from the text for the agent
+        # Todo: use a function that either reads from cache or gets room kind again.
+        if self._room_kinds.get(message.room_id) is None:
+            room_viewer_state = await client.get_room(message.room_id) 
+            if room_viewer_state is None:
+                return
+            if room_viewer_state.room is None:
+                return
+            self._room_kinds[message.room_id] = room_viewer_state.room.kind or RoomKind.UNSPECIFIED
+
         room_kind = self._room_kinds.get(message.room_id)
         room_kind = self._room_kinds.get(message.room_id)
-        chat_type = "dm" if room_kind == RoomKind.DM else room_kind or RoomKind.UNSPECIFIED # Only "dm" seems to be a reserved keyword from Base adapter class.
+
+        logger.info("message_body: %s room_kind: %s", message_body, room_kind)
 
 
         mentioned = False
         mentioned = False
-        if (room_kind is RoomKind.CHANNEL and self.chatto_config.require_mention.value):
+        if (room_kind == RoomKind.CHANNEL and self.chatto_config.require_mention.value):
             if self.me.login and not mentioned:
             if self.me.login and not mentioned:
-                mentioned = f"@{self.me.login}" in message_body
+                mentioned = bool(f"@{self.me.login}" in message_body)
             if self.me.display_name and not mentioned:
             if self.me.display_name and not mentioned:
-                mentioned = f"@{self.me.display_name}" in message_body
-            if not mentioned:
+                mentioned = bool(f"@{self.me.display_name}" in message_body)
+            if mentioned is False:
+                logger.debug("Discarding message. Bot was not mentionend but require_mention is '%s'.", self.chatto_config.require_mention.value)
                 return
                 return
+              
+        logger.info("mentioned: %s", mentioned)
 
 
         # Thread anchoring — if the incoming message is inside a thread, we
         # Thread anchoring — if the incoming message is inside a thread, we
         # keep that thread by default; otherwise leave thread_id unset so
         # keep that thread by default; otherwise leave thread_id unset so
         # replies land at the root.
         # replies land at the root.
         thread_id = payload.thread_root_event_id or None
         thread_id = payload.thread_root_event_id or None
-        if not thread_id and chat_type != "dm" and self.chatto_config.auto_thread.value:
+        if not thread_id and room_kind != RoomKind.DM and self.chatto_config.auto_thread.value:
             thread_id = message.id
             thread_id = message.id
             
             
         source = self.build_source(
         source = self.build_source(
             chat_id=payload.room_id,
             chat_id=payload.room_id,
             chat_name=self._room_names.get(message.room_id),
             chat_name=self._room_names.get(message.room_id),
-            chat_type=chat_type,
+            chat_type="dm" if room_kind == RoomKind.DM else room_kind or RoomKind.UNSPECIFIED, # Only "dm" seems to be a reserved keyword from Base adapter class.,
             user_id=message.actor_id,
             user_id=message.actor_id,
             user_name=user.login, # use login, because display_name is changeable by anyone.
             user_name=user.login, # use login, because display_name is changeable by anyone.
             thread_id=thread_id,
             thread_id=thread_id,
@@ -462,7 +472,34 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
         await self.handle_message(message_event)
         await self.handle_message(message_event)
         return
         return
-    
+
+    async def _handle_realtime_event(self, event: RealtimeEvent) -> None:
+        if self._is_seen(event.id):
+            return
+
+        if event.actor_id is None:
+            return
+
+        logger.info("EVENT happened: '%s' from %s", event.kind, event.actor_id)
+
+        if (event_payload := event.get("message_posted")) is not None:
+
+            # Self-event filter — the actor_id on the envelope is authoritative
+            # (chattolib does NOT filter this itself; see chatto-bridge notes).
+            actor_id = event.actor_id
+            if actor_id and actor_id == self.me.id:
+                return
+
+            await self._dispatch_message_posted(event_payload)
+
+        # confirmed:
+        elif event.kind in ("mention_notification", "projection_event", "presence_changed", "notification_dismissed",
+                            "room_marked_as_read", "user_typing", "notification_created", "new_direct_message_notification",
+                            'reaction_removed', 'reaction_added'):
+            logger.debug("Chatto: '%s' event received. Not yet implemented or not needed.", event.kind)
+        else:
+            logger.error("Chatto: unknown event kind: '%s'", event.kind)
+
 
 
     async def _chattolib_event_loop(self) -> None:
     async def _chattolib_event_loop(self) -> None:
         """Event loop using chattolib's stream_events.
         """Event loop using chattolib's stream_events.
@@ -470,82 +507,74 @@ class ChattoAdapter(BasePlatformAdapter):
         This replaces the manual WebSocket loop with chattolib's high-level
         This replaces the manual WebSocket loop with chattolib's high-level
         stream_events() which provides pre-decoded RealtimeEvent objects.
         stream_events() which provides pre-decoded RealtimeEvent objects.
         """
         """
-        try:
-            client = await self._require_client()
-        except RuntimeError:
-            logger.warning("Chatto: chattolib event loop aborted - no client available")
-            return
+
         # 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
         backoff = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF
         backoff = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF
-    
-        await self._refresh_rooms()
-        logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
 
 
+        delay = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF
         while not self._closing:
         while not self._closing:
-
-            backoff = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF
-            logger.info("Outside of event stream")
-
             try:
             try:
-                # Start streaming events
-                async for event in stream_events(client):
-
-                    if self._is_seen(event.id):
-                        continue
-
-                    # Signal that we're connected and ready
-                    if not self._ws_ready.is_set():
-                        self._ws_active = True
-                        self._ws_ready.set()
+                client = await self._require_client()
 
 
-                    
-                    logger.info("EVENT happened: event: %s from %s", event.kind, event.actor_id)
+                await self._refresh_rooms()
+                logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
 
 
-                    if (event_payload := event.get("message_posted")) is not None:
+                async for event in stream_events(client):
+                    if self._closing:
+                        return
+                    await self._handle_realtime_event(event)
+                # Iterator exited cleanly — treat as a normal close and reconnect
+                # with the local backoff (no server hint available).
+                logger.info("Chatto: realtime stream ended, reconnecting")
+            except asyncio.CancelledError:
+                return
+            except ChattoRealtimeCloseError as exc:
+                if not exc.reconnect:
+                    logger.error(
+                        "Chatto: realtime closed by server (%s: %s), not reconnecting",
+                        exc.code, exc.message,
+                    )
+                    return
+                wait = max(exc.retry_after_ms / 1000.0, ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF)
+                logger.warning(
+                    "Chatto: realtime closed by server (%s), reconnecting in %.1fs",
+                    exc.code, wait,
+                )
+                delay = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF  # server hint supersedes local backoff
+                await self._sleep_interruptible(wait)
+                continue
+            except ChattoRealtimeError as exc:
+                if getattr(exc, "fatal", False):
+                    logger.error("Chatto: fatal realtime error (%s): %s", exc.code, exc.message)
+                    return
+                logger.warning(
+                    "Chatto: realtime error (%s: %s), reconnecting in %.1fs",
+                    exc.code, exc.message, delay,
+                )
+            except Exception as exc:
+                logger.warning(
+                    "Chatto: unexpected realtime error: %s, reconnecting in %.1fs",
+                    exc, delay,
+                )
 
 
-                        # Self-event filter — the actor_id on the envelope is authoritative
-                        # (chattolib does NOT filter this itself; see chatto-bridge notes).
-                        actor_id = event.actor_id
-                        if actor_id and actor_id == self.me.id:
-                            return
+            if self._closing:
+                return
 
 
-                        await self._dispatch_message_posted(event_payload)
-
-                    # confirmed:
-                    elif event.kind in ("mention_notification", "projection_event", "presence_changed", "notification_dismissed",
-                                        "room_marked_as_read", "user_typing", "notification_created"):
-                        logger.debug("Chatto: '%s' event received. Not yet implemented or not needed.", event.kind)
-                    else:
-                        logger.error("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
-                    await asyncio.sleep(backoff)
-                    backoff = min(backoff * 2, ChattoConstants.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 (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, ChattoConstants.WS_RECONNECT_MAX_BACKOFF)
+            jitter = delay * 0.2 * random.random()
+            await self._sleep_interruptible(delay + jitter)
+            delay = min(delay * 2, ChattoConstants.WS_RECONNECT_MAX_BACKOFF)
 
 
             await asyncio.sleep(backoff)
             await asyncio.sleep(backoff)
 
 
 
 
-        # If we didn't find the event, refresh known rooms
-        # by delegating to a dedicated helper. This avoids duplicate logic
-        # across transient/projection event handlers.
-        await self._refresh_rooms()
+    async def _sleep_interruptible(self, seconds: float) -> None:
+        """Sleep in short slices so disconnect() cancels promptly."""
+        end = asyncio.get_running_loop().time() + seconds
+        while not self._closing:
+            remaining = end - asyncio.get_running_loop().time()
+            if remaining <= 0:
+                return
+            await asyncio.sleep(min(remaining, 0.5))
 
 
 
 
     async def _refresh_rooms(self) -> None:
     async def _refresh_rooms(self) -> None:
@@ -582,7 +611,6 @@ class ChattoAdapter(BasePlatformAdapter):
             for rid in new_room_ids:
             for rid in new_room_ids:
                 if self._room_kinds.get(rid) != RoomKind.DM:
                 if self._room_kinds.get(rid) != RoomKind.DM:
                     await client.join_room(rid) # but list_rooms() would not return any if we were not already joined?!
                     await client.join_room(rid) # but list_rooms() would not return any if we were not already joined?!
-                self._seen.append(rid) 
                 await self._seed_room(rid)
                 await self._seed_room(rid)
                 self._watch_room_ids.append(rid)
                 self._watch_room_ids.append(rid)
 
 
@@ -653,8 +681,8 @@ class ChattoAdapter(BasePlatformAdapter):
             if not thread_id:
             if not thread_id:
                 thread_id = reply_to
                 thread_id = reply_to
         # Check if this is a DM room — DMs don't support threads
         # Check if this is a DM room — DMs don't support threads
-        room_kind = self._room_kinds.get(str(chat_id), "")
-        is_dm = room_kind == "ROOM_KIND_DM" or room_kind == "dm"
+        room_kind = self._room_kinds.get(chat_id)
+        is_dm = room_kind == RoomKind.DM
         if is_dm:
         if is_dm:
             thread_id = None
             thread_id = None
 
 
@@ -676,7 +704,7 @@ class ChattoAdapter(BasePlatformAdapter):
         for i, chunk in enumerate(chunks):
         for i, chunk in enumerate(chunks):
             try:
             try:
                 msg_obj = await client.post_message(
                 msg_obj = await client.post_message(
-                    room_id=str(chat_id),
+                    room_id=chat_id,
                     body=chunk,
                     body=chunk,
                     thread_root_event_id=str(thread_id) if thread_id else "",
                     thread_root_event_id=str(thread_id) if thread_id else "",
                 )
                 )
@@ -710,11 +738,7 @@ class ChattoAdapter(BasePlatformAdapter):
         # Thread following (best-effort, Chatto-unique)
         # Thread following (best-effort, Chatto-unique)
         # ------------------------------------------------------------------ #
         # ------------------------------------------------------------------ #
         if thread_id and message_ids:
         if thread_id and message_ids:
-            try:
                 await client.follow_thread(chat_id, thread_id)
                 await client.follow_thread(chat_id, thread_id)
-            except Exception:
-                logger.debug("Chatto: _follow_thread failed for room=%s thread=%s",
-                             chat_id, thread_id, exc_info=True)
 
 
         return SendResult(success=True, message_id=first_id, raw_response=last_resp)
         return SendResult(success=True, message_id=first_id, raw_response=last_resp)
 
 
@@ -795,25 +819,26 @@ class ChattoAdapter(BasePlatformAdapter):
         # Already a shortcode like "thumbsup" — return as-is
         # Already a shortcode like "thumbsup" — return as-is
         return emoji
         return emoji
 
 
-    async def add_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
+    async def add_reaction(self, room_id: str, message_id: str, emoji: str) -> bool:
         """Add a reaction to a message via MessageService/AddReaction."""
         """Add a reaction to a message via MessageService/AddReaction."""
         shortcode = self._emoji_to_shortcode(emoji)
         shortcode = self._emoji_to_shortcode(emoji)
         try:
         try:
             try:
             try:
                 client = await self._require_client()
                 client = await self._require_client()
             except RuntimeError:
             except RuntimeError:
+                logger.warning("Chatto: AddReaction — client unavailable")
                 return False
                 return False
             result = await client.add_reaction(
             result = await client.add_reaction(
-                room_id=chat_id,
+                room_id=room_id,
                 message_event_id=message_id,
                 message_event_id=message_id,
                 emoji=shortcode,
                 emoji=shortcode,
             )
             )
             return result
             return result
         except ChattoError as e:
         except ChattoError as e:
-            logger.debug("Chatto: AddReaction failed: %s", e)
+            logger.warning("Chatto: AddReaction failed: %s", e)
             return False
             return False
         except Exception as e:
         except Exception as e:
-            logger.debug("Chatto: AddReaction error: %s", e)
+            logger.warning("Chatto: AddReaction error: %s", e)
             return False
             return False
 
 
     async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
     async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
@@ -823,6 +848,7 @@ class ChattoAdapter(BasePlatformAdapter):
             try:
             try:
                 client = await self._require_client()
                 client = await self._require_client()
             except RuntimeError:
             except RuntimeError:
+                logger.warning("Chatto: RemoveReaction — client unavailable")
                 return False
                 return False
             result = await client.remove_reaction(
             result = await client.remove_reaction(
                 room_id=str(chat_id),
                 room_id=str(chat_id),
@@ -831,10 +857,10 @@ class ChattoAdapter(BasePlatformAdapter):
             )
             )
             return result
             return result
         except ChattoError as e:
         except ChattoError as e:
-            logger.debug("Chatto: RemoveReaction failed: %s", e)
+            logger.warning("Chatto: RemoveReaction failed: %s", e)
             return False
             return False
         except Exception as e:
         except Exception as e:
-            logger.debug("Chatto: RemoveReaction error: %s", e)
+            logger.warning("Chatto: RemoveReaction error: %s", e)
             return False
             return False
 
 
 
 
@@ -855,8 +881,8 @@ class ChattoAdapter(BasePlatformAdapter):
             except RuntimeError:
             except RuntimeError:
                 return None
                 return None
             room = await client.start_dm(participant_ids=[str(user_id)])
             room = await client.start_dm(participant_ids=[str(user_id)])
-            self._room_names[room.id] = self._room_names.get(room.id, "")
-            self._room_kinds[room.id] = "ROOM_KIND_DM"
+            self._room_names[room.id] = room.name
+            self._room_kinds[room.id] = room.kind
             return room.id
             return room.id
         except ChattoError as e:
         except ChattoError as e:
             logger.debug("Chatto: StartDM failed: %s", e)
             logger.debug("Chatto: StartDM failed: %s", e)
@@ -895,8 +921,8 @@ class ChattoAdapter(BasePlatformAdapter):
             )
             )
             rid = str(room.id) if room else ""
             rid = str(room.id) if room else ""
             if rid:
             if rid:
-                self._room_names[rid] = name
-                self._room_kinds[rid] = "ROOM_KIND_GROUP"
+                self._room_names[rid] = room.name
+                self._room_kinds[rid] = room.kind
                 return rid
                 return rid
             logger.debug("Chatto: CreateRoom returned no room id")
             logger.debug("Chatto: CreateRoom returned no room id")
             return None
             return None
@@ -925,9 +951,14 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
         BasePlatformAdapter override
         BasePlatformAdapter override
         """
         """
+        logger.info("self.chatto_config.reactions.value: %s", self.chatto_config.reactions.value)
         if not self.chatto_config.reactions.value:
         if not self.chatto_config.reactions.value:
             return
             return
+        
         chat_id, message_id = self._event_room_and_message_id(event)
         chat_id, message_id = self._event_room_and_message_id(event)
+        if not chat_id or not message_id:
+            logger.warning("Chatto: on_processing_start — empty chat_id or message_id, skipping reaction")
+            return
         await self.add_reaction(chat_id, message_id, "👀")
         await self.add_reaction(chat_id, message_id, "👀")
 
 
     async def on_processing_complete(
     async def on_processing_complete(

+ 6 - 6
platform_config.py

@@ -117,7 +117,7 @@ def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str | bool], def
         return default.strip() 
         return default.strip() 
     return None
     return None
 
 
-def _get_env_or_extra_str(env_var: str, extra_val: Optional[str], default: Optional[str] = None) -> str:
+def _get_env_or_extra_str(env_var: str, extra_val: Optional[str | bool], default: Optional[str] = None) -> str:
     """Get a value from environment variable or extra config."""
     """Get a value from environment variable or extra config."""
     my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
     my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
     if my_string:
     if my_string:
@@ -127,7 +127,7 @@ def _get_env_or_extra_str(env_var: str, extra_val: Optional[str], default: Optio
 
 
 def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str | bool], default: bool = False) -> bool:
 def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str | bool], default: bool = False) -> bool:
     """Get a boolean value from environment variable or extra config."""
     """Get a boolean value from environment variable or extra config."""
-    return utils.is_truthy_value(_get_env_or_extra_str(env_var, str(extra_val), "False"), default)
+    return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val, str(default)), default)
 
 
 
 
 def _split_str_to_list(mystring: str) -> list:
 def _split_str_to_list(mystring: str) -> list:
@@ -229,19 +229,19 @@ class ChattoConfiguration:
         self.allowed_users.value = _get_env_or_extra_list(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))
         logger.info("self.allowed_users.value: %s", self.allowed_users.value)
         logger.info("self.allowed_users.value: %s", self.allowed_users.value)
 
 
-        self.require_mention.value = _get_env_or_extra_truthy(self.require_mention.env_name, pconfig.extra.get(self.require_mention.field_name))
+        self.require_mention.value = _get_env_or_extra_truthy(self.require_mention.env_name, pconfig.extra.get(self.require_mention.field_name), False)
         logger.info("self.require_mention.value: %s", self.require_mention.value)
         logger.info("self.require_mention.value: %s", self.require_mention.value)
 
 
-        # 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" when require_mention is true.
         self.free_response_channels_list.value = _get_env_or_extra_list(self.free_response_channels_list.env_name,
         self.free_response_channels_list.value = _get_env_or_extra_list(self.free_response_channels_list.env_name,
                                                                    pconfig.extra.get(self.free_response_channels_list.field_name))
                                                                    pconfig.extra.get(self.free_response_channels_list.field_name))
 
 
         # 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.value = _get_env_or_extra_truthy(self.auto_thread.env_name, pconfig.extra.get(self.auto_thread.field_name))
+        self.auto_thread.value = _get_env_or_extra_truthy(self.auto_thread.env_name, pconfig.extra.get(self.auto_thread.field_name), True)
 
 
         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.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))
+        self.reactions.value = _get_env_or_extra_truthy(self.reactions.env_name, pconfig.extra.get(self.reactions.field_name), True)
 
 
         logger.info("ChattoConfiguration: %s", self)
         logger.info("ChattoConfiguration: %s", self)

+ 4 - 0
plugin.yaml

@@ -51,4 +51,8 @@ optional_env:
   - name: CHATTO_FREE_RESPONSE_CHANNELS
   - name: CHATTO_FREE_RESPONSE_CHANNELS
     description: "Comma-separated room IDs where the bot responds without being tagged"
     description: "Comma-separated room IDs where the bot responds without being tagged"
     prompt: "Free-response room IDs (comma-separated)"
     prompt: "Free-response room IDs (comma-separated)"
+    password: false
+  - name: CHATTO_REACTIONS
+    description: "Add 👀/✅/❌ reactions to messages during processing (default: true)"
+    prompt: "Enable message reactions? (true/false)"
     password: false
     password: false

+ 45 - 18
test_adapter.py

@@ -31,17 +31,19 @@ sys.path.insert(0, "/opt/hermes")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 
 from adapter import (
 from adapter import (
-    _EMOJI_TO_SHORTCODE,
-    _MAX_MESSAGE_LENGTH,
-    _SEEN_CAP,
     ChattoAdapter,
     ChattoAdapter,
-    check_requirements,
-    validate_config,
+    hermes_check_fn as check_requirements,
+    hermes_validate_config as validate_config,
     register,
     register,
 )
 )
+from platform_config import ChattoConstants
 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
 
 
+_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
+_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
+_SEEN_CAP = ChattoConstants.SEEN_CAP
+
 
 
 # -- Helpers --
 # -- Helpers --
 
 
@@ -78,10 +80,11 @@ def _ensure_chatto_registered():
 
 
 
 
 _CHATTO_ENV_KEYS = [
 _CHATTO_ENV_KEYS = [
-    "CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
+    "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
     "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
     "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
     "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
     "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
-    "CHATTO_ALLOW_ALL_USERS",
+    "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
+    "CHATTO_REACTIONS",
 ]
 ]
 
 
 
 
@@ -97,7 +100,7 @@ def _clear_chatto_env(monkeypatch=None):
 def _make_config(**extra_overrides):
 def _make_config(**extra_overrides):
     """Create a minimal PlatformConfig for testing."""
     """Create a minimal PlatformConfig for testing."""
     _ensure_chatto_registered()
     _ensure_chatto_registered()
-    extra = {"url": "https://chat.example.com", "channels": ["room1"]}
+    extra = {"base_url": "https://chat.example.com", "channels": ["room1"]}
     extra.update(extra_overrides)
     extra.update(extra_overrides)
     return PlatformConfig(enabled=True, extra=extra)
     return PlatformConfig(enabled=True, extra=extra)
 
 
@@ -173,23 +176,18 @@ class TestRegistration:
         assert ctx.registered_kwargs["label"] == "Chatto"
         assert ctx.registered_kwargs["label"] == "Chatto"
 
 
     def test_check_requirements(self):
     def test_check_requirements(self):
-        _clear_chatto_env()
-        os.environ["CHATTO_URL"] = "https://chat.example.com"
-        os.environ["CHATTO_LOGIN"] = "user"
-        os.environ["CHATTO_PASSWORD"] = "pass"
         assert check_requirements() is True
         assert check_requirements() is True
-        _clear_chatto_env()
 
 
     def test_check_requirements_missing(self):
     def test_check_requirements_missing(self):
-        _clear_chatto_env()
-        assert check_requirements() is False
+        with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
+            assert check_requirements() is False
 
 
     def test_validate_config(self):
     def test_validate_config(self):
         _clear_chatto_env()
         _clear_chatto_env()
-        os.environ["CHATTO_URL"] = "https://chat.test"
+        os.environ["CHATTO_BASE_URL"] = "https://chat.test"
         os.environ["CHATTO_LOGIN"] = "user"
         os.environ["CHATTO_LOGIN"] = "user"
         os.environ["CHATTO_PASSWORD"] = "pass"
         os.environ["CHATTO_PASSWORD"] = "pass"
-        cfg = PlatformConfig(enabled=True, extra={"url": "https://chat.test"})
+        cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
         assert validate_config(cfg) is True
         assert validate_config(cfg) is True
         _clear_chatto_env()
         _clear_chatto_env()
 
 
@@ -246,13 +244,42 @@ class TestReactions:
         return adapter
         return adapter
 
 
     async def test_send_reaction(self, adapter):
     async def test_send_reaction(self, adapter):
-        await adapter.send_reaction("room-1", "msg-1", "👍")
+        await adapter.add_reaction("room-1", "msg-1", "👍")
         adapter._chatto_client.add_reaction.assert_called_once()
         adapter._chatto_client.add_reaction.assert_called_once()
 
 
     async def test_remove_reaction(self, adapter):
     async def test_remove_reaction(self, adapter):
         await adapter.remove_reaction("room-1", "msg-1", "👍")
         await adapter.remove_reaction("room-1", "msg-1", "👍")
         adapter._chatto_client.remove_reaction.assert_called_once()
         adapter._chatto_client.remove_reaction.assert_called_once()
 
 
+    async def test_on_processing_start_adds_eyes_reaction(self, adapter):
+        """on_processing_start should call add_reaction with 👀."""
+        event = MagicMock()
+        event.message_id = "msg-1"
+        event.source.chat_id = "room-1"
+        await adapter.on_processing_start(event)
+        adapter._chatto_client.add_reaction.assert_called_once()
+        call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
+        assert call_kwargs["message_event_id"] == "msg-1"
+        assert call_kwargs["room_id"] == "room-1"
+        assert call_kwargs["emoji"] == "eyes"
+
+    async def test_on_processing_start_empty_message_id(self, adapter):
+        """on_processing_start should skip reaction when message_id is empty."""
+        event = MagicMock()
+        event.message_id = None
+        event.source.chat_id = "room-1"
+        await adapter.on_processing_start(event)
+        adapter._chatto_client.add_reaction.assert_not_called()
+
+    async def test_on_processing_start_reactions_disabled(self, adapter):
+        """on_processing_start should skip when reactions config is False."""
+        adapter.chatto_config.reactions.value = False
+        event = MagicMock()
+        event.message_id = "msg-1"
+        event.source.chat_id = "room-1"
+        await adapter.on_processing_start(event)
+        adapter._chatto_client.add_reaction.assert_not_called()
+
 
 
 # -- Edit and Delete Messages --
 # -- Edit and Delete Messages --
 
 

+ 16 - 0
test_platform_config.py

@@ -44,6 +44,22 @@ class TestPlatformConfigHelpers:
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_truthy("CHATTO_TEST", "false") is False
         assert _get_env_or_extra_truthy("CHATTO_TEST", "false") is False
 
 
+    def test_get_env_or_extra_truthy_default_true_when_none_and_no_extra(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_truthy("CHATTO_TEST", None, default=True) is True
+
+    def test_get_env_or_extra_truthy_default_false_when_none_and_no_extra(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_truthy("CHATTO_TEST", None, default=False) is False
+
+    def test_get_env_or_extra_truthy_explicit_bool_true(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_truthy("CHATTO_TEST", True, default=False) is True
+
+    def test_get_env_or_extra_truthy_explicit_bool_false(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_truthy("CHATTO_TEST", False, default=True) is False
+
     def test_split_str_to_list_handles_comma_separated_values(self):
     def test_split_str_to_list_handles_comma_separated_values(self):
         assert _split_str_to_list("a,b, c ,d") == ["a", "b", "c", "d"]
         assert _split_str_to_list("a,b, c ,d") == ["a", "b", "c", "d"]