|
|
@@ -35,6 +35,7 @@ import hashlib
|
|
|
import logging
|
|
|
import mimetypes
|
|
|
import os
|
|
|
+import threading
|
|
|
from collections import OrderedDict
|
|
|
from datetime import datetime, timezone
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
@@ -54,6 +55,35 @@ from gateway.config import Platform
|
|
|
# Chattolib imports (lazy loaded)
|
|
|
from tools.lazy_deps import lazy_import
|
|
|
|
|
|
+# Thread-safe singleton helper
|
|
|
+from plugins.plugin_utils import SingletonSlot
|
|
|
+
|
|
|
+
|
|
|
+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
|
|
|
+
|
|
|
+ def reset(self):
|
|
|
+ """Reset the singleton instance."""
|
|
|
+ with self._lock:
|
|
|
+ self._instance = None
|
|
|
+ self._future = None
|
|
|
+
|
|
|
ChattoClient = lazy_import("chattolib", "ChattoClient")
|
|
|
ChattoError = lazy_import("chattolib", "ChattoError")
|
|
|
ChattoAuthError = lazy_import("chattolib", "ChattoAuthError")
|
|
|
@@ -169,8 +199,9 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
|
|
|
)
|
|
|
|
|
|
- # --- Chattolib client ---
|
|
|
- self._chatto_client: Optional[ChattoClient] = None
|
|
|
+ # --- Chattolib client (thread-safe lazy singleton) ---
|
|
|
+ self._client_slot: AsyncSingletonSlot = AsyncSingletonSlot()
|
|
|
+ self._chatto_client: Optional[ChattoClient] = None # Cached client instance
|
|
|
|
|
|
# --- Runtime state ---
|
|
|
self._token: Optional[str] = None
|
|
|
@@ -205,7 +236,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
async def _get_chatto_client(self) -> Optional[ChattoClient]:
|
|
|
- """Get or create a ChattoClient instance."""
|
|
|
+ """Get or create a ChattoClient instance using thread-safe async singleton."""
|
|
|
+ # Fast path: return cached client if available
|
|
|
if self._chatto_client is not None:
|
|
|
return self._chatto_client
|
|
|
|
|
|
@@ -214,13 +246,16 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
- self._chatto_client = await ChattoClient.login(
|
|
|
- self._login,
|
|
|
- self._password,
|
|
|
- base_url=self._base_url,
|
|
|
+ client = await self._client_slot.get(
|
|
|
+ lambda: ChattoClient.login(
|
|
|
+ self._login,
|
|
|
+ self._password,
|
|
|
+ base_url=self._base_url,
|
|
|
+ )
|
|
|
)
|
|
|
+ self._chatto_client = client # Cache for direct access
|
|
|
logger.info("Chatto: logged in as %s via chattolib", self._login)
|
|
|
- return self._chatto_client
|
|
|
+ return client
|
|
|
except ChattoAuthError as e:
|
|
|
logger.error("Chatto: authentication failed: %s", e)
|
|
|
self._set_fatal_error("auth_failed", str(e), retryable=True)
|
|
|
@@ -230,6 +265,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self._set_fatal_error("client_error", str(e), retryable=True)
|
|
|
return None
|
|
|
|
|
|
+ async def _client(self) -> ChattoClient:
|
|
|
+ """Helper to get the chattolib client. Thread-safe via AsyncSingletonSlot."""
|
|
|
+ return await self._get_chatto_client()
|
|
|
+
|
|
|
async def _ensure_token(self) -> bool:
|
|
|
"""Login via chattolib."""
|
|
|
if self._token:
|
|
|
@@ -245,7 +284,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
async def _relogin(self) -> bool:
|
|
|
"""Force re-login (token expired)."""
|
|
|
self._token = None
|
|
|
- self._chatto_client = None # Also clear chattolib client
|
|
|
+ self._chatto_client = None # Clear cached client
|
|
|
+ self._client_slot.reset() # Clear chattolib client singleton
|
|
|
return await self._ensure_token()
|
|
|
|
|
|
|
|
|
@@ -258,7 +298,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
if not await self._ensure_token():
|
|
|
return False
|
|
|
|
|
|
- client = self._chatto_client
|
|
|
+ client = await self._get_chatto_client()
|
|
|
|
|
|
# Get our own user info
|
|
|
try:
|
|
|
@@ -391,6 +431,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
pass
|
|
|
self._ws_task = None
|
|
|
self._token = None
|
|
|
+ self._chatto_client = None
|
|
|
+ self._client_slot.reset()
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
# Liveness probe
|
|
|
@@ -438,7 +480,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
if not self._running:
|
|
|
return
|
|
|
try:
|
|
|
- await self._chatto_client.get_viewer()
|
|
|
+ client = await self._get_chatto_client()
|
|
|
+ await client.get_viewer()
|
|
|
failures = 0
|
|
|
# Refresh presence to keep showing as online
|
|
|
try:
|
|
|
@@ -479,7 +522,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
async def _join_room(self, room_id: str) -> None:
|
|
|
"""Join a room if not already a member."""
|
|
|
try:
|
|
|
- await self._chatto_client.join_room(room_id=room_id)
|
|
|
+ client = await self._get_chatto_client()
|
|
|
+ await client.join_room(room_id=room_id)
|
|
|
logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
|
|
|
except ChattoError as e:
|
|
|
if "permission_denied" in str(e).lower() or "403" in str(e):
|
|
|
@@ -493,9 +537,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
room_service_pb2 = lazy_import("chattolib._pb.chatto.api.v1", "room_service_pb2")
|
|
|
pb_to_dict = lazy_import("chattolib._transport", "pb_to_dict")
|
|
|
|
|
|
- resp = await self._chatto_client.services.rooms.get_room_events(
|
|
|
+ client = await self._get_chatto_client()
|
|
|
+ resp = await client.services.rooms.get_room_events(
|
|
|
room_service_pb2.GetRoomEventsRequest(room_id=room_id),
|
|
|
- headers=self._chatto_client._headers(),
|
|
|
+ headers=client._headers(),
|
|
|
)
|
|
|
data = pb_to_dict(resp)
|
|
|
events = data.get("page", {}).get("events", [])
|
|
|
@@ -556,7 +601,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
This replaces the manual WebSocket loop with chattolib's high-level
|
|
|
stream_events() which provides pre-decoded RealtimeEvent objects.
|
|
|
"""
|
|
|
- client = self._chatto_client
|
|
|
+ client = await self._get_chatto_client()
|
|
|
|
|
|
backoff = _WS_RECONNECT_INITIAL_BACKOFF
|
|
|
|
|
|
@@ -1331,7 +1376,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
"""Add a reaction to a message via MessageService/AddReaction."""
|
|
|
shortcode = self._emoji_to_shortcode(emoji)
|
|
|
try:
|
|
|
- result = await self._chatto_client.add_reaction(
|
|
|
+ client = await self._client()
|
|
|
+ result = await client.add_reaction(
|
|
|
room_id=str(chat_id),
|
|
|
message_event_id=str(message_id),
|
|
|
emoji=shortcode,
|