|
@@ -75,7 +75,7 @@ try:
|
|
|
MessagePostedPayload,
|
|
MessagePostedPayload,
|
|
|
ReactionPayload,
|
|
ReactionPayload,
|
|
|
)
|
|
)
|
|
|
- from chattolib.types import PresenceStatus, RoomKind, User
|
|
|
|
|
|
|
+ from chattolib.types import PresenceStatus, 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
|
|
@@ -239,6 +239,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self._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] = []
|
|
|
|
|
+ # One-shot guard for the unjoined-home-channel warning in _refresh_rooms.
|
|
|
|
|
+ self._home_warning_logged = False
|
|
|
self._ws_task: Optional[asyncio.Task] = None
|
|
self._ws_task: Optional[asyncio.Task] = None
|
|
|
self._presence_task: Optional[asyncio.Task] = None
|
|
self._presence_task: Optional[asyncio.Task] = None
|
|
|
self._ws_ready: Optional[asyncio.Event] = None
|
|
self._ws_ready: Optional[asyncio.Event] = None
|
|
@@ -565,6 +567,137 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
logger.info("Chatto: rejecting message from unauthorized user '%s' (%s)", user.login, user.id)
|
|
logger.info("Chatto: rejecting message from unauthorized user '%s' (%s)", user.login, user.id)
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
|
|
+ # ------------------------------------------------------------------ #
|
|
|
|
|
+ # Room management over DM (/join, /leave)
|
|
|
|
|
+ # ------------------------------------------------------------------ #
|
|
|
|
|
+
|
|
|
|
|
+ _DM_COMMANDS = ("/join", "/leave")
|
|
|
|
|
+
|
|
|
|
|
+ async def _handle_dm_command(self, room_id: str, body: str) -> bool:
|
|
|
|
|
+ """Run a ``/join`` or ``/leave`` admin command sent as a direct message.
|
|
|
|
|
+
|
|
|
|
|
+ Returns True when ``body`` is one of the commands — whether it
|
|
|
|
|
+ succeeded or not — so the caller keeps it out of the agent pipeline.
|
|
|
|
|
+ Membership lives on the Chatto server: a joined room reappears in
|
|
|
|
|
+ every future ``list_rooms()`` and therefore survives restarts.
|
|
|
|
|
+ """
|
|
|
|
|
+ verb, _, argument = body.strip().partition(" ")
|
|
|
|
|
+ if verb.lower() not in self._DM_COMMANDS:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ client = await self._require_client()
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ await self.send(chat_id=room_id, content="Chatto client is not connected.")
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ argument = argument.strip()
|
|
|
|
|
+ if not argument:
|
|
|
|
|
+ await self.send(
|
|
|
|
|
+ chat_id=room_id,
|
|
|
|
|
+ content="Usage: /join <room-id or #name> | /leave <room-id or #name>",
|
|
|
|
|
+ )
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ error, target = await self._resolve_room_target(client, argument)
|
|
|
|
|
+ if error or target is None:
|
|
|
|
|
+ await self.send(chat_id=room_id, content=error or "Room lookup failed.")
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ if verb.lower() == "/join":
|
|
|
|
|
+ reply = await self._run_join(client, target)
|
|
|
|
|
+ else:
|
|
|
|
|
+ reply = await self._run_leave(client, target)
|
|
|
|
|
+ await self.send(chat_id=room_id, content=reply)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ async def _resolve_room_target(
|
|
|
|
|
+ self, client: ChattoClient, argument: str,
|
|
|
|
|
+ ) -> tuple[str | None, RoomWithViewerState | None]:
|
|
|
|
|
+ """Resolve a /join//leave argument to a room.
|
|
|
|
|
+
|
|
|
|
|
+ ``#name`` is looked up case-insensitively in a fresh directory scan
|
|
|
|
|
+ (which also refreshes our name/kind caches); anything else is treated
|
|
|
|
|
+ as a room ID and verified via GetRoom. An ambiguous name comes back as
|
|
|
|
|
+ an error naming the candidates, so the admin can retry with an ID.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not argument.startswith("#"):
|
|
|
|
|
+ state = await client.get_room(argument)
|
|
|
|
|
+ if state is None or state.room is None:
|
|
|
|
|
+ return f"No room with ID '{argument}'.", None
|
|
|
|
|
+ return None, state
|
|
|
|
|
+
|
|
|
|
|
+ wanted = argument[1:].strip().casefold()
|
|
|
|
|
+ matches: list[RoomWithViewerState] = []
|
|
|
|
|
+ for state in await client.list_rooms() or []:
|
|
|
|
|
+ room_obj = state.room if state else None
|
|
|
|
|
+ if room_obj and (room_obj.name or "").strip().casefold() == wanted:
|
|
|
|
|
+ matches.append(state)
|
|
|
|
|
+ self._room_names[room_obj.id] = room_obj.name
|
|
|
|
|
+ self._room_kinds[room_obj.id] = room_obj.kind
|
|
|
|
|
+ if not matches:
|
|
|
|
|
+ return f"No room named '{argument}'.", None
|
|
|
|
|
+ if len(matches) > 1:
|
|
|
|
|
+ candidates = "\n".join(f"• {m.room.name} ({m.room.id})" for m in matches)
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"Several rooms are named '{argument}' — pick one by ID:\n"
|
|
|
|
|
+ f"{candidates}"
|
|
|
|
|
+ ), None
|
|
|
|
|
+ return None, matches[0]
|
|
|
|
|
+
|
|
|
|
|
+ async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
|
|
|
|
|
+ """Join a room via RoomService/JoinRoom and watch it immediately.
|
|
|
|
|
+
|
|
|
|
|
+ An account that already holds membership (invited natively in Chatto)
|
|
|
|
|
+ needs no JoinRoom call — it only gets seeded into the watch list.
|
|
|
|
|
+ """
|
|
|
|
|
+ room_obj = state.room
|
|
|
|
|
+ label = f"'{room_obj.name}' ({room_obj.id})"
|
|
|
|
|
+ joined_room = room_obj
|
|
|
|
|
+ if not state.viewer_state.is_member:
|
|
|
|
|
+ try:
|
|
|
|
|
+ joined_room = await client.join_room(room_obj.id) or room_obj
|
|
|
|
|
+ except ChattoError as exc:
|
|
|
|
|
+ logger.warning("Chatto: /join failed for %s (%s)", room_obj.id, exc)
|
|
|
|
|
+ return f"Could not join {label}: {exc}"
|
|
|
|
|
+ self._room_names[joined_room.id] = joined_room.name
|
|
|
|
|
+ self._room_kinds[joined_room.id] = joined_room.kind
|
|
|
|
|
+ if joined_room.id not in self._watch_room_ids:
|
|
|
|
|
+ await self._seed_room(joined_room.id)
|
|
|
|
|
+ self._watch_room_ids.append(joined_room.id)
|
|
|
|
|
+ if state.viewer_state.is_member:
|
|
|
|
|
+ return f"Already a member of {label} — watching it."
|
|
|
|
|
+ return f"Joined {label}."
|
|
|
|
|
+
|
|
|
|
|
+ async def _run_leave(self, client: ChattoClient, state: RoomWithViewerState) -> str:
|
|
|
|
|
+ """Leave a room via RoomService/LeaveRoom and stop watching it.
|
|
|
|
|
+
|
|
|
|
|
+ Two rooms are refused: a DM conversation cannot be left, and leaving
|
|
|
|
|
+ the configured home channel would silently break cron/notification
|
|
|
|
|
+ delivery, which posts there through the standalone sender.
|
|
|
|
|
+ """
|
|
|
|
|
+ room_obj = state.room
|
|
|
|
|
+ label = f"'{room_obj.name}' ({room_obj.id})"
|
|
|
|
|
+ if room_obj.kind == RoomKind.DM:
|
|
|
|
|
+ return "Direct messages cannot be left."
|
|
|
|
|
+ home_id = (self.chatto_config.home_channel.value or "").strip()
|
|
|
|
|
+ if home_id == room_obj.id:
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"{label} is the configured home channel "
|
|
|
|
|
+ "(CHATTO_HOME_CHANNEL); leaving it would break cron and "
|
|
|
|
|
+ "notification delivery. Point CHATTO_HOME_CHANNEL elsewhere first."
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ left = await client.leave_room(room_obj.id)
|
|
|
|
|
+ except ChattoError as exc:
|
|
|
|
|
+ logger.warning("Chatto: /leave failed for %s (%s)", room_obj.id, exc)
|
|
|
|
|
+ return f"Could not leave {label}: {exc}"
|
|
|
|
|
+ if not left:
|
|
|
|
|
+ return f"Chatto refused to leave {label}."
|
|
|
|
|
+ if room_obj.id in self._watch_room_ids:
|
|
|
|
|
+ self._watch_room_ids.remove(room_obj.id)
|
|
|
|
|
+ return f"Left {label}."
|
|
|
|
|
+
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
|
# Inbound attachments
|
|
# Inbound attachments
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
@@ -719,6 +852,14 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
logger.info("message_body: %s room_kind: %s", message_body, room_kind)
|
|
logger.info("message_body: %s room_kind: %s", message_body, room_kind)
|
|
|
|
|
|
|
|
|
|
+ # Membership commands ride in over DMs only: they change what the bot
|
|
|
|
|
+ # listens to and must never reach the agent pipeline or the mention
|
|
|
|
|
+ # gates.
|
|
|
|
|
+ if room_kind == RoomKind.DM and await self._handle_dm_command(
|
|
|
|
|
+ message.room_id, message_body,
|
|
|
|
|
+ ):
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
# require_mention deliberately gates channels only: in a channel the bot
|
|
# require_mention deliberately gates channels only: in a channel the bot
|
|
|
# is one of many listeners and must be addressed, whereas a DM is already
|
|
# is one of many listeners and must be addressed, whereas a DM is already
|
|
|
# addressed at it — so DMs are always answered, mention or not.
|
|
# addressed at it — so DMs are always answered, mention or not.
|
|
@@ -946,6 +1087,29 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
await asyncio.sleep(min(remaining, 0.5))
|
|
await asyncio.sleep(min(remaining, 0.5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ def _warn_if_home_channel_unjoined(self, member_ids: set[str]) -> None:
|
|
|
|
|
+ """Warn once when CHATTO_HOME_CHANNEL names a room the bot is not in.
|
|
|
|
|
+
|
|
|
|
|
+ Standalone cron delivery posts straight into that room with a fresh
|
|
|
|
|
+ client and no join logic of its own — without server-side membership
|
|
|
|
|
+ every proactive send fails there.
|
|
|
|
|
+ """
|
|
|
|
|
+ home_id = (self.chatto_config.home_channel.value or "").strip()
|
|
|
|
|
+ if (
|
|
|
|
|
+ not home_id
|
|
|
|
|
+ or home_id in member_ids
|
|
|
|
|
+ or home_id in self._watch_room_ids
|
|
|
|
|
+ or self._home_warning_logged
|
|
|
|
|
+ ):
|
|
|
|
|
+ return
|
|
|
|
|
+ self._home_warning_logged = True
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "Chatto: CHATTO_HOME_CHANNEL '%s' is not a joined room - cron and "
|
|
|
|
|
+ "notification delivery will fail until the bot joins it (invite "
|
|
|
|
|
+ "the account natively in Chatto, or DM it '/join').",
|
|
|
|
|
+ home_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
async def _refresh_rooms(self) -> None:
|
|
async def _refresh_rooms(self) -> None:
|
|
|
"""Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
|
|
"""Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
|
|
|
try:
|
|
try:
|
|
@@ -956,6 +1120,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
rooms_list = await client.list_rooms()
|
|
rooms_list = await client.list_rooms()
|
|
|
|
|
+ member_ids: set[str] = set()
|
|
|
new_room_ids: List[str] = []
|
|
new_room_ids: List[str] = []
|
|
|
|
|
|
|
|
for room_with_state in rooms_list:
|
|
for room_with_state in rooms_list:
|
|
@@ -969,9 +1134,28 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
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 room_with_state.viewer_state.is_member and room_obj.id not in self._watch_room_ids:
|
|
|
|
|
|
|
+ if not room_with_state.viewer_state.is_member:
|
|
|
|
|
+ continue
|
|
|
|
|
+ member_ids.add(room_obj.id)
|
|
|
|
|
+ if room_obj.id not in self._watch_room_ids:
|
|
|
new_room_ids.append(room_obj.id)
|
|
new_room_ids.append(room_obj.id)
|
|
|
|
|
|
|
|
|
|
+ # Watched rooms we no longer belong to (left via /leave, kicked,
|
|
|
|
|
+ # deleted) drop out here — otherwise the next refresh would
|
|
|
|
|
+ # quietly re-add what /leave just removed.
|
|
|
|
|
+ stale_room_ids = [
|
|
|
|
|
+ rid for rid in self._watch_room_ids if rid not in member_ids
|
|
|
|
|
+ ]
|
|
|
|
|
+ for rid in stale_room_ids:
|
|
|
|
|
+ self._watch_room_ids.remove(rid)
|
|
|
|
|
+ if stale_room_ids:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Chatto WS: no longer a member of %d room(s): %s",
|
|
|
|
|
+ len(stale_room_ids), stale_room_ids,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ self._warn_if_home_channel_unjoined(member_ids)
|
|
|
|
|
+
|
|
|
if not new_room_ids:
|
|
if not new_room_ids:
|
|
|
return
|
|
return
|
|
|
|
|
|