|
@@ -13,8 +13,6 @@ from __future__ import annotations
|
|
|
|
|
|
|
|
import random
|
|
import random
|
|
|
|
|
|
|
|
-from gateway.platforms.helpers import MessageDeduplicator
|
|
|
|
|
-
|
|
|
|
|
# Put the vendored dependencies for THIS platform on sys.path before importing
|
|
# Put the vendored dependencies for THIS platform on sys.path before importing
|
|
|
# anything from chattolib. Imported relatively as part of the plugin package and
|
|
# anything from chattolib. Imported relatively as part of the plugin package and
|
|
|
# absolutely when this module is loaded standalone (e.g. by the tests).
|
|
# absolutely when this module is loaded standalone (e.g. by the tests).
|
|
@@ -77,7 +75,13 @@ try:
|
|
|
MessagePostedPayload,
|
|
MessagePostedPayload,
|
|
|
ReactionPayload,
|
|
ReactionPayload,
|
|
|
)
|
|
)
|
|
|
- from chattolib.types import PresenceStatus, RoomKind, RoomWithViewerState, User
|
|
|
|
|
|
|
+ from chattolib.types import (
|
|
|
|
|
+ PresenceStatus,
|
|
|
|
|
+ Room,
|
|
|
|
|
+ RoomKind,
|
|
|
|
|
+ RoomWithViewerState,
|
|
|
|
|
+ User,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
except ImportError as e:
|
|
except ImportError as e:
|
|
|
# Fail loudly: continuing here only defers the failure to a confusing
|
|
# Fail loudly: continuing here only defers the failure to a confusing
|
|
@@ -257,24 +261,20 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
|
|
self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
|
|
|
|
|
|
|
|
# ------ State -------
|
|
# ------ State -------
|
|
|
- # SDK runtime handle (injected by Hermes); annotate for Pylance
|
|
|
|
|
- self.sdk: Any = getattr(self, "sdk", None)
|
|
|
|
|
-
|
|
|
|
|
# Our own user, filled in by connect(). Events arriving before connect()
|
|
# Our own user, filled in by connect(). Events arriving before connect()
|
|
|
# completes must not blow up on an undefined attribute.
|
|
# completes must not blow up on an undefined attribute.
|
|
|
self.me: User | None = None
|
|
self.me: User | None = None
|
|
|
|
|
|
|
|
# --- Runtime state ---
|
|
# --- Runtime state ---
|
|
|
- self._user_id: str = ""
|
|
|
|
|
- self._user_display: str = ""
|
|
|
|
|
self._room_names: dict[str, str] = {}
|
|
self._room_names: dict[str, str] = {}
|
|
|
self._room_kinds: dict[str, RoomKind] = {}
|
|
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 = (
|
|
self._our_message_ids: set = (
|
|
|
set()
|
|
set()
|
|
|
) # message IDs we sent (for thread root detection)
|
|
) # message IDs we sent (for thread root detection)
|
|
|
- self._seen: list[str] = [] # Plain RealtimeEvent-id list
|
|
|
|
|
- self._resume_cursor: str | None = None
|
|
|
|
|
|
|
+ # Event IDs already processed — chattolib may redeliver events across
|
|
|
|
|
+ # reconnects, so every inbound event is checked against this list.
|
|
|
|
|
+ self._seen: list[str] = []
|
|
|
self._watch_room_ids: list[str] = []
|
|
self._watch_room_ids: list[str] = []
|
|
|
# Rooms the server force-joined everyone into (Room.universal) — used
|
|
# Rooms the server force-joined everyone into (Room.universal) — used
|
|
|
# only for [universal] tags in the watch log, never for gating.
|
|
# only for [universal] tags in the watch log, never for gating.
|
|
@@ -283,9 +283,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self._home_warning_logged = False
|
|
self._home_warning_logged = False
|
|
|
self._ws_task: asyncio.Task | None = None
|
|
self._ws_task: asyncio.Task | None = None
|
|
|
self._presence_task: asyncio.Task | None = None
|
|
self._presence_task: asyncio.Task | None = None
|
|
|
- self._ws_ready: asyncio.Event | None = None
|
|
|
|
|
- self._ws_active = False
|
|
|
|
|
- self._ws_ref = None # reference to open websocket for dynamic resubscribe
|
|
|
|
|
|
|
|
|
|
# Persistent typing indicator loops per room
|
|
# Persistent typing indicator loops per room
|
|
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
|
@@ -299,9 +296,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self._chatto_client: ChattoClient | None = None
|
|
self._chatto_client: ChattoClient | None = None
|
|
|
self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
|
|
self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
|
|
|
|
|
|
|
|
- # Dedup — chattolib may redeliver events across reconnects.
|
|
|
|
|
- self._dedup = MessageDeduplicator()
|
|
|
|
|
-
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
|
# Auth
|
|
# Auth
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
@@ -327,7 +321,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
token=self.chatto_config.token.value,
|
|
token=self.chatto_config.token.value,
|
|
|
)
|
|
)
|
|
|
self._chatto_client = client
|
|
self._chatto_client = client
|
|
|
- self._token = client.token
|
|
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"Chatto: logged in as '%s' via chattolib",
|
|
"Chatto: logged in as '%s' via chattolib",
|
|
|
self.chatto_config.login.value,
|
|
self.chatto_config.login.value,
|
|
@@ -420,7 +413,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
self._closing = False
|
|
self._closing = False
|
|
|
# Start background realtime WS event stream loop.
|
|
# Start background realtime WS event stream loop.
|
|
|
- self._ws_ready = asyncio.Event()
|
|
|
|
|
self._ws_task = asyncio.create_task(
|
|
self._ws_task = asyncio.create_task(
|
|
|
self._chattolib_event_loop(),
|
|
self._chattolib_event_loop(),
|
|
|
name="chatto-event-stream",
|
|
name="chatto-event-stream",
|
|
@@ -477,7 +469,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# No explicit offline broadcast: chattolib rejects OFFLINE outright
|
|
# No explicit offline broadcast: chattolib rejects OFFLINE outright
|
|
|
# ("stop refreshing to go offline"), so cancelling the refresh loop
|
|
# ("stop refreshing to go offline"), so cancelling the refresh loop
|
|
|
# below is what actually takes the bot offline.
|
|
# below is what actually takes the bot offline.
|
|
|
- self._ws_active = False
|
|
|
|
|
self._closing = True
|
|
self._closing = True
|
|
|
|
|
|
|
|
# Cancel all typing tasks
|
|
# Cancel all typing tasks
|
|
@@ -512,7 +503,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
finally:
|
|
finally:
|
|
|
self._chatto_client = None
|
|
self._chatto_client = None
|
|
|
|
|
|
|
|
- self._token = None
|
|
|
|
|
logger.info("Chatto: disconnected")
|
|
logger.info("Chatto: disconnected")
|
|
|
self._mark_disconnected()
|
|
self._mark_disconnected()
|
|
|
|
|
|
|
@@ -693,21 +683,23 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return None, state
|
|
return None, state
|
|
|
|
|
|
|
|
wanted = argument[1:].strip().casefold()
|
|
wanted = argument[1:].strip().casefold()
|
|
|
- matches: list[RoomWithViewerState] = []
|
|
|
|
|
|
|
+ # (state, room) pairs: a listed match's room is already narrowed here,
|
|
|
|
|
+ # so the candidate listing below needs no fresh Optional dance.
|
|
|
|
|
+ matches: list[tuple[RoomWithViewerState, Room]] = []
|
|
|
for state in await client.list_rooms() or []:
|
|
for state in await client.list_rooms() or []:
|
|
|
room_obj = state.room if state else None
|
|
room_obj = state.room if state else None
|
|
|
if room_obj and (room_obj.name or "").strip().casefold() == wanted:
|
|
if room_obj and (room_obj.name or "").strip().casefold() == wanted:
|
|
|
- matches.append(state)
|
|
|
|
|
|
|
+ matches.append((state, room_obj))
|
|
|
self._room_names[room_obj.id] = room_obj.name
|
|
self._room_names[room_obj.id] = room_obj.name
|
|
|
self._room_kinds[room_obj.id] = room_obj.kind
|
|
self._room_kinds[room_obj.id] = room_obj.kind
|
|
|
if not matches:
|
|
if not matches:
|
|
|
return f"No room named '{argument}'.", None
|
|
return f"No room named '{argument}'.", None
|
|
|
if len(matches) > 1:
|
|
if len(matches) > 1:
|
|
|
- candidates = "\n".join(f"• {m.room.name} ({m.room.id})" for m in matches)
|
|
|
|
|
|
|
+ candidates = "\n".join(f"• {room.name} ({room.id})" for _, room in matches)
|
|
|
return (
|
|
return (
|
|
|
f"Several rooms are named '{argument}' — pick one by ID:\n{candidates}"
|
|
f"Several rooms are named '{argument}' — pick one by ID:\n{candidates}"
|
|
|
), None
|
|
), None
|
|
|
- return None, matches[0]
|
|
|
|
|
|
|
+ return None, matches[0][0]
|
|
|
|
|
|
|
|
async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
|
|
async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
|
|
|
"""Join a room via RoomService/JoinRoom and watch it immediately.
|
|
"""Join a room via RoomService/JoinRoom and watch it immediately.
|
|
@@ -716,6 +708,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
needs no JoinRoom call — it only gets seeded into the watch list.
|
|
needs no JoinRoom call — it only gets seeded into the watch list.
|
|
|
"""
|
|
"""
|
|
|
room_obj = state.room
|
|
room_obj = state.room
|
|
|
|
|
+ if room_obj is None:
|
|
|
|
|
+ # Unreachable via _resolve_room_target: both of its paths only
|
|
|
|
|
+ # return states whose room they already inspected.
|
|
|
|
|
+ return "Chatto returned an empty room record — try again."
|
|
|
label = f"'{room_obj.name}' ({room_obj.id})"
|
|
label = f"'{room_obj.name}' ({room_obj.id})"
|
|
|
joined_room = room_obj
|
|
joined_room = room_obj
|
|
|
if not state.viewer_state.is_member:
|
|
if not state.viewer_state.is_member:
|
|
@@ -741,6 +737,9 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
delivery, which posts there through the standalone sender.
|
|
delivery, which posts there through the standalone sender.
|
|
|
"""
|
|
"""
|
|
|
room_obj = state.room
|
|
room_obj = state.room
|
|
|
|
|
+ if room_obj is None:
|
|
|
|
|
+ # Same invariant as _run_join: _resolve_room_target pre-inspects.
|
|
|
|
|
+ return "Chatto returned an empty room record — try again."
|
|
|
label = f"'{room_obj.name}' ({room_obj.id})"
|
|
label = f"'{room_obj.name}' ({room_obj.id})"
|
|
|
if room_obj.kind == RoomKind.DM:
|
|
if room_obj.kind == RoomKind.DM:
|
|
|
return "Direct messages cannot be left."
|
|
return "Direct messages cannot be left."
|