Parcourir la source

Keep refreshing presence so the bot stays online

Presence is a TTL the server lets lapse, not a flag: chattolib rejects
OFFLINE outright ("stop refreshing to go offline"). We announced ONLINE
once at connect and never again, so the bot dropped offline when the TTL
expired and never came back — reconnects did not re-announce either.

Refresh on an interval for as long as we are connected, and let
disconnect() cancel that task instead of sending an OFFLINE that always
raised ValueError into a swallowed debug log. Presence failures now log
at warning, since a dropped call looked exactly like a bot that is down.
Paul Klumpp il y a 1 semaine
Parent
commit
2191cd6729
4 fichiers modifiés avec 118 ajouts et 13 suppressions
  1. 3 1
      DOCS.md
  2. 43 12
      adapter.py
  3. 5 0
      platform_config.py
  4. 67 0
      test_adapter.py

+ 3 - 1
DOCS.md

@@ -265,7 +265,9 @@ The adapter maintains a cached member directory using `ListUsers`, `GetUser`, an
 
 ### Presence Broadcasting
 
-The adapter can broadcast presence status (online, away, do-not-disturb) via `UpdatePresence`. On startup, the bot sets itself to online. Status can be changed programmatically.
+The adapter can broadcast presence status (online, away, do-not-disturb) via `UpdatePresence`. Status can be changed programmatically.
+
+Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePresence` rejects `OFFLINE` outright ("stop refreshing to go offline"). The bot therefore announces itself online on startup and a background task re-announces it every `PRESENCE_REFRESH_INTERVAL` seconds (default 60) until `disconnect()` cancels that task. Going offline is exactly that cancellation — the adapter never sends an offline status.
 
 | Presence Status | API Value |
 |-----------------|-----------|

+ 43 - 12
adapter.py

@@ -240,6 +240,7 @@ class ChattoAdapter(BasePlatformAdapter):
         self._resume_cursor: Optional[str] = None
         self._watch_room_ids: List[str] = []
         self._ws_task: Optional[asyncio.Task] = None
+        self._presence_task: Optional[asyncio.Task] = None
         self._ws_ready: Optional[asyncio.Event] = None
         self._ws_active = False
         self._ws_ref = None  # reference to open websocket for dynamic resubscribe
@@ -375,11 +376,10 @@ class ChattoAdapter(BasePlatformAdapter):
                 self._chatto_client = None
             return False
 
-        # Broadcast online presence so the bot appears online in the member list
-        try:
-            await client.update_presence(status=PresenceStatus.ONLINE)
-        except Exception:
-            logger.debug("Chatto: update_presence(online) failed on connect", exc_info=True)
+        # Announce online presence so the bot appears online in the member list.
+        # The server treats this as a TTL, so _presence_refresh_loop below has to
+        # keep re-announcing it — a single call here lapses back to offline.
+        await self._announce_online()
 
         self._closing = False
         # Start background realtime WS event stream loop.
@@ -387,6 +387,9 @@ class ChattoAdapter(BasePlatformAdapter):
         self._ws_task = asyncio.create_task(
             self._chattolib_event_loop(), name="chatto-event-stream",
         )
+        self._presence_task = asyncio.create_task(
+            self._presence_refresh_loop(), name="chatto-presence-refresh",
+        )
         self._mark_connected()
 
         self._list_functions()
@@ -399,19 +402,40 @@ class ChattoAdapter(BasePlatformAdapter):
 
         return True
 
-    async def disconnect(self) -> None:
-        """Stop WebSocket, liveness probe, typing tasks, and clear state.
+    async def _announce_online(self) -> bool:
+        """Tell the server we are online. Returns whether the call got through.
 
-        BasePlatformAdapter override
+        Logged at warning level on failure: a silently dropped presence call is
+        indistinguishable from a bot that is simply not running.
         """
-        # Broadcast offline presence before tearing down
         try:
             client = await self._require_client()
-            await client.update_presence(status=PresenceStatus.OFFLINE)
-        except Exception:
-            logger.debug("Chatto: update_presence(PresenceStatus.OFFLINE) failed on disconnect", exc_info=True)
+            await client.update_presence(status=PresenceStatus.ONLINE)
+            return True
+        except Exception as exc:
+            logger.warning("Chatto: presence refresh failed, bot may appear offline: %s", exc)
+            return False
 
+    async def _presence_refresh_loop(self) -> None:
+        """Re-announce ONLINE until disconnect, since presence expires server-side.
 
+        Failures are not fatal — the next tick tries again, so a blip in the
+        presence endpoint costs at most one interval of visible offline time.
+        """
+        while not self._closing:
+            await self._sleep_interruptible(ChattoConstants.PRESENCE_REFRESH_INTERVAL)
+            if self._closing:
+                return
+            await self._announce_online()
+
+    async def disconnect(self) -> None:
+        """Stop WebSocket, presence refresh, typing tasks, and clear state.
+
+        BasePlatformAdapter override
+        """
+        # No explicit offline broadcast: chattolib rejects OFFLINE outright
+        # ("stop refreshing to go offline"), so cancelling the refresh loop
+        # below is what actually takes the bot offline.
         self._ws_active = False
         self._closing = True
 
@@ -427,6 +451,13 @@ class ChattoAdapter(BasePlatformAdapter):
                 pass
             self._ws_task = None
 
+        if self._presence_task and not self._presence_task.done():
+            self._presence_task.cancel()
+            try:
+                await self._presence_task
+            except (asyncio.CancelledError, Exception):
+                pass
+        self._presence_task = None
 
         if self._chatto_client:
             try:

+ 5 - 0
platform_config.py

@@ -62,6 +62,11 @@ class ChattoConstants:
     WS_RECONNECT_INITIAL_BACKOFF = 1.0
     WS_RECONNECT_MAX_BACKOFF = 30.0
 
+    # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
+    # at all ("stop refreshing to go offline"), so staying online means
+    # re-announcing ONLINE on this interval for as long as we are connected.
+    PRESENCE_REFRESH_INTERVAL = 60.0
+
     # HTTP timeout used for outbound URL fetches
     HTTP_TIMEOUT = 30
 

+ 67 - 0
test_adapter.py

@@ -45,6 +45,7 @@ from chattolib.types import (
     AssetUrl,
     Message,
     MessageAttachment,
+    PresenceStatus,
     Room,
     RoomKind,
     User,
@@ -713,6 +714,72 @@ class TestChatTypeMapping:
         assert event.source.chat_type == "channel"
 
 
+# -- Presence --
+
+class TestPresence:
+    """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        adapter._chatto_client.update_presence = AsyncMock()
+        return adapter
+
+    async def test_refresh_loop_keeps_reannouncing_online(self):
+        """The bug: a single announce at connect lapses and never comes back."""
+        adapter = self._adapter()
+        adapter._closing = False
+        with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
+            task = asyncio.create_task(adapter._presence_refresh_loop())
+            for _ in range(200):
+                if adapter._chatto_client.update_presence.await_count >= 3:
+                    break
+                await asyncio.sleep(0.01)
+            adapter._closing = True
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+        assert adapter._chatto_client.update_presence.await_count >= 3
+        for call in adapter._chatto_client.update_presence.await_args_list:
+            assert call.kwargs["status"] == PresenceStatus.ONLINE
+
+    async def test_refresh_survives_a_failing_call(self):
+        """One bad tick must not kill the loop and strand the bot offline."""
+        adapter = self._adapter()
+        adapter._closing = False
+        adapter._chatto_client.update_presence = AsyncMock(
+            side_effect=[RuntimeError("boom"), None, None])
+        with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
+            task = asyncio.create_task(adapter._presence_refresh_loop())
+            for _ in range(200):
+                if adapter._chatto_client.update_presence.await_count >= 3:
+                    break
+                await asyncio.sleep(0.01)
+            adapter._closing = True
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+        assert adapter._chatto_client.update_presence.await_count >= 3
+
+    async def test_announce_online_reports_failure(self):
+        adapter = self._adapter()
+        adapter._chatto_client.update_presence = AsyncMock(side_effect=RuntimeError("nope"))
+        assert await adapter._announce_online() is False
+
+    async def test_disconnect_does_not_broadcast_offline(self):
+        """chattolib raises ValueError on OFFLINE — going offline means stopping."""
+        adapter = self._adapter()
+        adapter._chatto_client.close = AsyncMock()
+        client = adapter._chatto_client  # disconnect() drops the reference
+        await adapter.disconnect()
+        client.update_presence.assert_not_called()
+
+
 # -- require_mention --
 
 class TestRequireMention: