|
@@ -12,6 +12,20 @@ including both outbound messaging and realtime WebSocket connections.
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
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 asyncio
|
|
|
import hashlib
|
|
import hashlib
|
|
|
import logging
|
|
import logging
|
|
@@ -40,27 +54,27 @@ from gateway.config import Platform, PlatformConfig
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
# Try vendored chattolib first
|
|
# Try vendored chattolib first
|
|
|
- from vendor.chattolib.client import (
|
|
|
|
|
|
|
+ from .vendor.chattolib.client import (
|
|
|
ChattoClient,
|
|
ChattoClient,
|
|
|
)
|
|
)
|
|
|
- from vendor.chattolib.exceptions import (
|
|
|
|
|
|
|
+ from .vendor.chattolib.exceptions import (
|
|
|
ChattoAuthError,
|
|
ChattoAuthError,
|
|
|
ChattoError,
|
|
ChattoError,
|
|
|
)
|
|
)
|
|
|
- from vendor.chattolib.realtime import (
|
|
|
|
|
|
|
+ from .vendor.chattolib.realtime import (
|
|
|
ChattoRealtimeError,
|
|
ChattoRealtimeError,
|
|
|
ChattoRealtimeCloseError,
|
|
ChattoRealtimeCloseError,
|
|
|
RealtimeEvent,
|
|
RealtimeEvent,
|
|
|
stream_events,
|
|
stream_events,
|
|
|
)
|
|
)
|
|
|
- from vendor.chattolib.types import (
|
|
|
|
|
|
|
+ from .vendor.chattolib.types import (
|
|
|
PresenceStatus,
|
|
PresenceStatus,
|
|
|
)
|
|
)
|
|
|
- from vendor.chattolib._pb.chatto.api.v1 import (
|
|
|
|
|
|
|
+ from .vendor.chattolib._pb.chatto.api.v1 import (
|
|
|
rooms_pb2 as room_service_pb2,
|
|
rooms_pb2 as room_service_pb2,
|
|
|
threads_pb2 as thread_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:
|
|
except ImportError as e:
|
|
|
logger.error("Chatto: failed to import vendored chattolib: %s", e)
|
|
logger.error("Chatto: failed to import vendored chattolib: %s", e)
|
|
@@ -110,6 +124,11 @@ def _decode_event_envelope(data: bytes) -> dict:
|
|
|
# Adapter
|
|
# Adapter
|
|
|
# --------------------------------------------------------------------------- #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
+def hermes_adapter_factory(config: PlatformConfig):
|
|
|
|
|
+ """Factory wrapper that constructs ChattoAdapter from a PlatformConfig."""
|
|
|
|
|
+ return ChattoAdapter(config)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class ChattoAdapter(BasePlatformAdapter):
|
|
class ChattoAdapter(BasePlatformAdapter):
|
|
|
"""Chatto platform adapter — receives messages via WebSocket realtime,
|
|
"""Chatto platform adapter — receives messages via WebSocket realtime,
|
|
|
sends via ConnectRPC REST."""
|
|
sends via ConnectRPC REST."""
|
|
@@ -121,11 +140,16 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
def __init__(self, pconfig: PlatformConfig, **kwargs):
|
|
def __init__(self, pconfig: PlatformConfig, **kwargs):
|
|
|
"""Signature needs to be compatible with BasePlatformAdapter.__init__ """
|
|
"""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.
|
|
# "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 ---
|
|
# --- Configuration from our configuration data class with some logic ---
|
|
|
self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
|
|
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 -------
|
|
# ------ State -------
|
|
|
# SDK runtime handle (injected by Hermes); annotate for Pylance
|
|
# SDK runtime handle (injected by Hermes); annotate for Pylance
|
|
@@ -180,7 +204,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
client = await self._open_client(
|
|
client = await self._open_client(
|
|
|
base_url=self.chatto_config.base_url.value,
|
|
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,
|
|
password=self.chatto_config.password.value,
|
|
|
token=self.chatto_config.token.value,
|
|
token=self.chatto_config.token.value,
|
|
|
)
|
|
)
|
|
@@ -209,7 +233,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
async def _ensure_token(self) -> bool:
|
|
async def _ensure_token(self) -> bool:
|
|
|
"""Ensure we have a logged-in Chatto client and token."""
|
|
"""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
|
|
return True
|
|
|
|
|
|
|
|
client = await self._get_chatto_client()
|
|
client = await self._get_chatto_client()
|
|
@@ -225,6 +249,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# Connection
|
|
# Connection
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
|
async def _open_client(
|
|
async def _open_client(
|
|
|
|
|
+ self,
|
|
|
*,
|
|
*,
|
|
|
base_url: str,
|
|
base_url: str,
|
|
|
login: str,
|
|
login: str,
|
|
@@ -239,6 +264,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
|
"""Connect to Chatto and start the realtime event stream."""
|
|
"""Connect to Chatto and start the realtime event stream."""
|
|
|
|
|
+ logger.info("Chatto: connecting...")
|
|
|
|
|
|
|
|
if not await self._ensure_token():
|
|
if not await self._ensure_token():
|
|
|
return False
|
|
return False
|
|
@@ -252,10 +278,11 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# Get our own user info
|
|
# Get our own user info
|
|
|
try:
|
|
try:
|
|
|
me = await client.me()
|
|
me = await client.me()
|
|
|
|
|
+ self.me = me
|
|
|
self._user_id = me.id
|
|
self._user_id = me.id
|
|
|
self._user_login = me.login
|
|
self._user_login = me.login
|
|
|
self._user_display = me.display_name or ""
|
|
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:
|
|
except Exception as e:
|
|
|
logger.error("Chatto: failed to get user info: %s", e)
|
|
logger.error("Chatto: failed to get user info: %s", e)
|
|
|
return False
|
|
return False
|
|
@@ -282,7 +309,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
rooms.append(entry)
|
|
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:
|
|
except Exception as e:
|
|
|
logger.error("Chatto: failed to list rooms: %s", e)
|
|
logger.error("Chatto: failed to list rooms: %s", e)
|
|
|
return False
|
|
return False
|
|
@@ -308,7 +335,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
all_room_ids.append(rid)
|
|
all_room_ids.append(rid)
|
|
|
|
|
|
|
|
if self.chatto_config.channels_list.value:
|
|
if self.chatto_config.channels_list.value:
|
|
|
- watch = list(self.chatto_config.channels_list.value)
|
|
|
|
|
|
|
+ watch = self.chatto_config.channels_list.value
|
|
|
else:
|
|
else:
|
|
|
watch = all_room_ids
|
|
watch = all_room_ids
|
|
|
|
|
|
|
@@ -322,8 +349,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
await self._join_room(rid)
|
|
await self._join_room(rid)
|
|
|
|
|
|
|
|
# Pick home channel
|
|
# 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
|
|
self._watch_room_ids = watch
|
|
|
|
|
|
|
@@ -531,22 +558,13 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
async def _start_chattolib_realtime(self) -> bool:
|
|
async def _start_chattolib_realtime(self) -> bool:
|
|
|
"""Start realtime connection using chattolib's stream_events."""
|
|
"""Start realtime connection using chattolib's stream_events."""
|
|
|
self._ws_ready = asyncio.Event()
|
|
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
|
|
return True
|
|
|
|
|
|
|
|
async def _chattolib_event_loop(self) -> None:
|
|
async def _chattolib_event_loop(self) -> None:
|
|
@@ -945,6 +963,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
pass
|
|
pass
|
|
|
else:
|
|
else:
|
|
|
logger.debug("Chatto WS: unknown transient event type: %s", event_type)
|
|
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:
|
|
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.
|
|
"""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)
|
|
logger.warning("Chatto WS: REST fallback fetch aborted - no client available for event %s", event_id)
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
|
|
+ logger.info("wat 1")
|
|
|
# Fetch events either from the thread timeline or the room timeline
|
|
# Fetch events either from the thread timeline or the room timeline
|
|
|
if thread_root_event_id:
|
|
if thread_root_event_id:
|
|
|
resp = await client.services.threads.get_thread_events(
|
|
resp = await client.services.threads.get_thread_events(
|
|
@@ -979,6 +999,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
data = pb_to_dict(resp)
|
|
data = pb_to_dict(resp)
|
|
|
events = data.get("page", {}).get("events", [])
|
|
events = data.get("page", {}).get("events", [])
|
|
|
|
|
+ logger.info("wat 2")
|
|
|
for ev in events:
|
|
for ev in events:
|
|
|
ev_id = str(ev.get("id", ""))
|
|
ev_id = str(ev.get("id", ""))
|
|
|
if ev_id == event_id:
|
|
if ev_id == event_id:
|
|
@@ -1008,16 +1029,21 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
message dict (decoded from protobuf) and dispatches it through the
|
|
message dict (decoded from protobuf) and dispatches it through the
|
|
|
standard Hermes message pipeline.
|
|
standard Hermes message pipeline.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ logger.info("Dispatching 1")
|
|
|
|
|
+
|
|
|
if not self._message_handler:
|
|
if not self._message_handler:
|
|
|
|
|
+ logger.info("Dispatching 2")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
actor_id = str(msg.get("actorId", ""))
|
|
actor_id = str(msg.get("actorId", ""))
|
|
|
# Skip our own messages
|
|
# Skip our own messages
|
|
|
if actor_id == self._user_id:
|
|
if actor_id == self._user_id:
|
|
|
|
|
+ logger.info("Dispatching 3")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
# Best-effort: cache the sender's display name for richer message context
|
|
# Best-effort: cache the sender's display name for richer message context
|
|
|
if actor_id and actor_id not in self._user_cache:
|
|
if actor_id and actor_id not in self._user_cache:
|
|
|
|
|
+ logger.info("Dispatching 4")
|
|
|
try:
|
|
try:
|
|
|
await self.get_user(actor_id)
|
|
await self.get_user(actor_id)
|
|
|
except Exception:
|
|
except Exception:
|
|
@@ -1025,6 +1051,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
body = str(msg.get("body", ""))
|
|
body = str(msg.get("body", ""))
|
|
|
if not body:
|
|
if not body:
|
|
|
|
|
+ logger.info("Dispatching 5")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
msg_id = str(msg.get("id", ""))
|
|
msg_id = str(msg.get("id", ""))
|
|
@@ -1034,13 +1061,17 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
is_dm = chat_type == "dm"
|
|
is_dm = chat_type == "dm"
|
|
|
mentioned = False
|
|
mentioned = False
|
|
|
if self._user_login:
|
|
if self._user_login:
|
|
|
|
|
+ logger.info("Dispatching 6")
|
|
|
mentioned = f"@{self._user_login}" in body
|
|
mentioned = f"@{self._user_login}" in body
|
|
|
if self._user_display:
|
|
if self._user_display:
|
|
|
|
|
+ logger.info("Dispatching 7")
|
|
|
mentioned = mentioned or f"@{self._user_display}" in body
|
|
mentioned = mentioned or f"@{self._user_display}" in body
|
|
|
|
|
|
|
|
if self.chatto_config.require_mention.value and not is_dm and not mentioned:
|
|
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)
|
|
# Allow free-response rooms (like Discord's free_response_channels)
|
|
|
if room_id not in self.chatto_config.free_response_channels_list.value:
|
|
if room_id not in self.chatto_config.free_response_channels_list.value:
|
|
|
|
|
+ logger.info("Dispatching 9")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
# For DMs, always respond. For rooms with require_mention, only respond when mentioned.
|
|
# For DMs, always respond. For rooms with require_mention, only respond when mentioned.
|
|
@@ -1059,6 +1090,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
thread_id = None
|
|
thread_id = None
|
|
|
thread_info = msg.get("thread", {})
|
|
thread_info = msg.get("thread", {})
|
|
|
if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
|
|
if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
|
|
|
|
|
+ logger.info("Dispatching 10")
|
|
|
thread_id = str(thread_info.get("threadRootEventId", ""))
|
|
thread_id = str(thread_info.get("threadRootEventId", ""))
|
|
|
# Hermes SDK: Propagate thread context if thread_id is set
|
|
# Hermes SDK: Propagate thread context if thread_id is set
|
|
|
try:
|
|
try:
|
|
@@ -1085,6 +1117,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
except (ValueError, TypeError):
|
|
except (ValueError, TypeError):
|
|
|
timestamp = datetime.now()
|
|
timestamp = datetime.now()
|
|
|
|
|
|
|
|
|
|
+ logger.info("Dispatching MessageEvent to Hermes")
|
|
|
event = MessageEvent(
|
|
event = MessageEvent(
|
|
|
text=text,
|
|
text=text,
|
|
|
message_type=MessageType.TEXT,
|
|
message_type=MessageType.TEXT,
|
|
@@ -2086,3 +2119,212 @@ async def hermes_standalone_sender_fn(
|
|
|
await client.close()
|
|
await client.close()
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
logger.error("Chatto standalone: error closing short-lived client. Perhaps already closed. {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."
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|