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