{"repo":"NousResearch/hermes-agent","public_id":"11163","display_label":"WebSocket reconnect handling and message drop fix","summary":"These members address a bug where the gateway silently drops messages during brief WebSocket disconnections, and include fixes to wait for reconnection before discarding sends. A related QQBot batch fix also touches WebSocket reconnect wait behavior alongside other changes.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11567,"merged_at":"2026-04-17T11:22:40+00:00"},"resolution":{"state":"resolved","by":"pr-11567"},"freshness":{"state":"stale","last_verified_at":"2026-04-17T11:22:40+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":11284,"pr_number":11567,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-17T11:22:40+00:00"}],"members":[{"kind":"issue","number":11163,"title":"[Bug]: Gateway silently drops messages when WebSocket platform adapter is briefly disconnected","url":null,"state":"closed"},{"kind":"pr","number":11164,"title":"fix(gateway): wait for WebSocket reconnection before dropping sends","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11567,"title":"fix(qqbot): Batch-2 salvage — SSRF allowlist, voice preflight, group auth, WS reconnect wait","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/qqbot.py","gateway/run.py","scripts/release.py","tests/gateway/test_qqbot.py","tests/gateway/test_unauthorized_dm_behavior.py","tests/tools/test_url_safety.py","tools/url_safety.py"],"fix_hunks":[{"file":"gateway/platforms/qqbot.py","hunks":["@@ -64,6 +64,7 @@\n     MessageEvent,\n     MessageType,\n     SendResult,\n+    _ssrf_redirect_guard,\n     cache_document_from_bytes,\n     cache_image_from_bytes,\n )","@@ -226,7 +227,11 @@ async def connect(self) -> bool:\n             return False\n \n         try:\n-            self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True)\n+            self._http_client = httpx.AsyncClient(\n+                timeout=30.0,\n+                follow_redirects=True,\n+                event_hooks={\"response\": [_ssrf_redirect_guard]},\n+            )\n \n             # 1. Get access token\n             await self._ensure_token()","@@ -1101,6 +1106,11 @@ async def _stt_voice_attachment(\n             is_pre_wav = True\n             logger.info(\"[QQ] STT: using voice_wav_url (pre-converted WAV)\")\n \n+        from tools.url_safety import is_safe_url\n+        if not is_safe_url(download_url):\n+            logger.warning(\"[QQ] STT blocked unsafe URL: %s\", download_url[:80])\n+            return None\n+\n         try:\n             # 2. Download audio (QQ CDN requires Authorization header)\n             if not self._http_client:","@@ -1525,6 +1535,33 @@ async def _upload_media(\n \n         raise last_exc  # type: ignore[misc]\n \n+    # Maximum time (seconds) to wait for reconnection before giving up on send.\n+    _RECONNECT_WAIT_SECONDS = 15.0\n+    # How often (seconds) to poll is_connected while waiting.\n+    _RECONNECT_POLL_INTERVAL = 0.5\n+\n+    async def _wait_for_reconnection(self) -> bool:\n+        \"\"\"Wait for the WebSocket listener to reconnect.\n+\n+        The listener loop (_listen_loop) auto-reconnects on disconnect, but\n+        there is a race window where send() is called right after a disconnect\n+        and before the reconnect completes.  This method polls is_connected\n+        for up to _RECONNECT_WAIT_SECONDS.\n+\n+        Returns True if reconnected, False if still disconnected.\n+        \"\"\"\n+        logger.info(\"[%s] Not connected — waiting for reconnection (up to %.0fs)\",\n+                    self.name, self._RECONNECT_WAIT_SECONDS)\n+        waited = 0.0\n+        while waited < self._RECONNECT_WAIT_SECONDS:\n+            await asyncio.sleep(self._RECONNECT_POLL_INTERVAL)\n+            waited += self._RECONNECT_POLL_INTERVAL\n+            if self.is_connected:\n+                logger.info(\"[%s] Reconnected after %.1fs\", self.name, waited)\n+                return True\n+        logger.warning(\"[%s] Still not connected after %.0fs\", self.name, self._RECONNECT_WAIT_SECONDS)\n+        return False\n+\n     async def send(\n         self,\n         chat_id: str,","@@ -1540,7 +1577,8 @@ async def send(\n         del metadata\n \n         if not self.is_connected:\n-            return SendResult(success=False, error=\"Not connected\")\n+            if not await self._wait_for_reconnection():\n+                return SendResult(success=False, error=\"Not connected\", retryable=True)\n \n         if not content or not content.strip():\n             return SendResult(success=True)","@@ -1741,7 +1779,8 @@ async def _send_media(\n     ) -> SendResult:\n         \"\"\"Upload media and send as a native message.\"\"\"\n         if not self.is_connected:\n-            return SendResult(success=False, error=\"Not connected\")\n+            if not await self._wait_for_reconnection():\n+                return SendResult(success=False, error=\"Not connected\", retryable=True)\n \n         try:\n             # Resolve media source"]},{"file":"gateway/run.py","hunks":["@@ -2618,6 +2618,9 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             Platform.BLUEBUBBLES: \"BLUEBUBBLES_ALLOWED_USERS\",\n             Platform.QQBOT: \"QQ_ALLOWED_USERS\",\n         }\n+        platform_group_env_map = {\n+            Platform.QQBOT: \"QQ_GROUP_ALLOWED_USERS\",\n+        }\n         platform_allow_all_map = {\n             Platform.TELEGRAM: \"TELEGRAM_ALLOW_ALL_USERS\",\n             Platform.DISCORD: \"DISCORD_ALLOW_ALL_USERS\",","@@ -2649,12 +2652,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n \n         # Check platform-specific and global allowlists\n         platform_allowlist = os.getenv(platform_env_map.get(source.platform, \"\"), \"\").strip()\n+        group_allowlist = \"\"\n+        if source.chat_type == \"group\":\n+            group_allowlist = os.getenv(platform_group_env_map.get(source.platform, \"\"), \"\").strip()\n         global_allowlist = os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").strip()\n \n-        if not platform_allowlist and not global_allowlist:\n+        if not platform_allowlist and not group_allowlist and not global_allowlist:\n             # No allowlists configured -- check global allow-all flag\n             return os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in (\"true\", \"1\", \"yes\")\n \n+        # Some platforms authorize group traffic by chat ID rather than sender ID.\n+        if group_allowlist and source.chat_type == \"group\" and source.chat_id:\n+            allowed_group_ids = {\n+                chat_id.strip() for chat_id in group_allowlist.split(\",\") if chat_id.strip()\n+            }\n+            if \"*\" in allowed_group_ids or source.chat_id in allowed_group_ids:\n+                return True\n+\n         # Check if user is in any allowlist\n         allowed_ids = set()\n         if platform_allowlist:"]},{"file":"scripts/release.py","hunks":["@@ -96,6 +96,7 @@\n     \"[redacted:email]\": \"wakamex\",\n     \"[redacted:email]\": \"clawdia-nash\",\n     \"[redacted:email]\": \"austinpickett\",\n+    \"[redacted:email]\": \"dieutx\",\n     \"[redacted:email]\": \"jaisup\",\n     \"[redacted:email]\": \"PercyDikec\",\n     \"[redacted:email]\": \"deankerr\",","@@ -235,6 +236,7 @@\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n+    \"[redacted:email]\": \"LehaoLin\",\n }\n \n "]},{"file":"tests/gateway/test_qqbot.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for the QQ Bot platform adapter.\"\"\"\n \n+import asyncio\n import json\n import os\n import sys","@@ -149,6 +150,47 @@ def test_audio_extension_amr(self):\n         assert self._fn(\"\", \"recording.amr\") is True\n \n \n+# ---------------------------------------------------------------------------\n+# Voice attachment SSRF protection\n+# ---------------------------------------------------------------------------\n+\n+class TestVoiceAttachmentSSRFProtection:\n+    def _make_adapter(self, **extra):\n+        from gateway.platforms.qqbot import QQAdapter\n+        return QQAdapter(_make_config(**extra))\n+\n+    def test_stt_blocks_unsafe_download_url(self):\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._http_client = mock.AsyncMock()\n+\n+        with mock.patch(\"tools.url_safety.is_safe_url\", return_value=False):\n+            transcript = asyncio.run(\n+                adapter._stt_voice_attachment(\n+                    \"http://127.0.0.1/voice.silk\",\n+                    \"audio/silk\",\n+                    \"voice.silk\",\n+                )\n+            )\n+\n+        assert transcript is None\n+        adapter._http_client.get.assert_not_called()\n+\n+    def test_connect_uses_redirect_guard_hook(self):\n+        from gateway.platforms.qqbot import QQAdapter, _ssrf_redirect_guard\n+\n+        client = mock.AsyncMock()\n+        with mock.patch(\"gateway.platforms.qqbot.httpx.AsyncClient\", return_value=client) as async_client_cls:\n+            adapter = QQAdapter(_make_config(app_id=\"a\", client_secret=\"b\"))\n+            adapter._ensure_token = [redacted:secret-assignment]\"stop after client creation\"))\n+\n+            connected = asyncio.run(adapter.connect())\n+\n+        assert connected is False\n+        assert async_client_cls.call_count == 1\n+        kwargs = async_client_cls.call_args.kwargs\n+        assert kwargs.get(\"follow_redirects\") is True\n+        assert kwargs.get(\"event_hooks\", {}).get(\"response\") == [_ssrf_redirect_guard]\n+\n # ---------------------------------------------------------------------------\n # _strip_at_mention\n # ---------------------------------------------------------------------------","@@ -458,3 +500,85 @@ def test_reply_to(self):\n         adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\", markdown_support=False)\n         body = adapter._build_text_body(\"reply text\", reply_to=\"msg_123\")\n         assert body.get(\"message_reference\", {}).get(\"message_id\") == \"msg_123\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _wait_for_reconnection / send reconnection wait\n+# ---------------------------------------------------------------------------\n+\n+class TestWaitForReconnection:\n+    \"\"\"Test that send() waits for reconnection instead of silently dropping.\"\"\"\n+\n+    def _make_adapter(self, **extra):\n+        from gateway.platforms.qqbot import QQAdapter\n+        return QQAdapter(_make_config(**extra))\n+\n+    @pytest.mark.asyncio\n+    async def test_send_waits_and_succeeds_on_reconnect(self):\n+        \"\"\"send() should wait for reconnection and then deliver the message.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        # Initially disconnected\n+        adapter._running = False\n+        adapter._http_client = mock.MagicMock()\n+\n+        # Simulate reconnection after 0.3s (faster than real interval)\n+        async def fake_api_request(*args, **kwargs):\n+            return {\"id\": \"msg_123\"}\n+\n+        adapter._api_request = fake_api_request\n+        adapter._ensure_token = [redacted:secret-assignment]\n+        adapter._RECONNECT_POLL_INTERVAL = 0.1\n+        adapter._RECONNECT_WAIT_SECONDS = 5.0\n+\n+        # Schedule reconnection after a short delay\n+        async def reconnect_after_delay():\n+            await asyncio.sleep(0.3)\n+            adapter._running = True\n+\n+        asyncio.get_event_loop().create_task(reconnect_after_delay())\n+\n+        result = await adapter.send(\"test_openid\", \"Hello, world!\")\n+        assert result.success\n+        assert result.message_id == \"msg_123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_returns_retryable_after_timeout(self):\n+        \"\"\"send() should return retryable=True if reconnection takes too long.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = False\n+        adapter._RECONNECT_POLL_INTERVAL = 0.05\n+        adapter._RECONNECT_WAIT_SECONDS = 0.2\n+\n+        result = await adapter.send(\"test_openid\", \"Hello, world!\")\n+        assert not result.success\n+        assert result.retryable is True\n+        assert \"Not connected\" in result.error\n+\n+    @pytest.mark.asyncio\n+    async def test_send_succeeds_immediately_when_connected(self):\n+        \"\"\"send() should not wait when already connected.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = True\n+        adapter._http_client = mock.MagicMock()\n+\n+        async def fake_api_request(*args, **kwargs):\n+            return {\"id\": \"msg_immediate\"}\n+\n+        adapter._api_request = fake_api_request\n+\n+        result = await adapter.send(\"test_openid\", \"Hello!\")\n+        assert result.success\n+        assert result.message_id == \"msg_immediate\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_media_waits_for_reconnect(self):\n+        \"\"\"_send_media should also wait for reconnection.\"\"\"\n+        adapter = self._make_adapter(app_id=\"a\", client_secret=\"b\")\n+        adapter._running = False\n+        adapter._RECONNECT_POLL_INTERVAL = 0.05\n+        adapter._RECONNECT_WAIT_SECONDS = 0.2\n+\n+        result = await adapter._send_media(\"test_openid\", \"http://example.com/img.jpg\", 1, \"image\")\n+        assert not result.success\n+        assert result.retryable is True\n+        assert \"Not connected\" in result.error"]},{"file":"tests/gateway/test_unauthorized_dm_behavior.py","hunks":["@@ -21,6 +21,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"MATTERMOST_ALLOWED_USERS\",\n         \"MATRIX_ALLOWED_USERS\",\n         \"DINGTALK_ALLOWED_USERS\", \"FEISHU_ALLOWED_USERS\", \"WECOM_ALLOWED_USERS\",\n+        \"QQ_ALLOWED_USERS\", \"QQ_GROUP_ALLOWED_USERS\",\n         \"GATEWAY_ALLOWED_USERS\",\n         \"TELEGRAM_ALLOW_ALL_USERS\",\n         \"DISCORD_ALLOW_ALL_USERS\",","@@ -32,6 +33,7 @@ def _clear_auth_env(monkeypatch) -> None:\n         \"MATTERMOST_ALLOW_ALL_USERS\",\n         \"MATRIX_ALLOW_ALL_USERS\",\n         \"DINGTALK_ALLOW_ALL_USERS\", \"FEISHU_ALLOW_ALL_USERS\", \"WECOM_ALLOW_ALL_USERS\",\n+        \"QQ_ALLOW_ALL_USERS\",\n         \"GATEWAY_ALLOW_ALL_USERS\",\n     ):\n         monkeypatch.delenv(key, raising=False)","@@ -130,6 +132,46 @@ def test_star_wildcard_works_for_any_platform(monkeypatch):\n     assert runner._is_user_authorized(source) is True\n \n \n+def test_qq_group_allowlist_authorizes_group_chat_without_user_allowlist(monkeypatch):\n+    _clear_auth_env(monkeypatch)\n+    monkeypatch.setenv(\"QQ_GROUP_ALLOWED_USERS\", \"group-openid-1\")\n+\n+    runner, _adapter = _make_runner(\n+        Platform.QQBOT,\n+        GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),\n+    )\n+\n+    source = SessionSource(\n+        platform=Platform.QQBOT,\n+        user_id=\"member-openid-999\",\n+        chat_id=\"group-openid-1\",\n+        user_name=\"tester\",\n+        chat_type=\"group\",\n+    )\n+\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_qq_group_allowlist_does_not_authorize_other_groups(monkeypatch):\n+    _clear_auth_env(monkeypatch)\n+    monkeypatch.setenv(\"QQ_GROUP_ALLOWED_USERS\", \"group-openid-1\")\n+\n+    runner, _adapter = _make_runner(\n+        Platform.QQBOT,\n+        GatewayConfig(platforms={Platform.QQBOT: PlatformConfig(enabled=True)}),\n+    )\n+\n+    source = SessionSource(\n+        platform=Platform.QQBOT,\n+        user_id=\"member-openid-999\",\n+        chat_id=\"group-openid-2\",\n+        user_name=\"tester\",\n+        chat_type=\"group\",\n+    )\n+\n+    assert runner._is_user_authorized(source) is False\n+\n+\n @pytest.mark.asyncio\n async def test_unauthorized_dm_pairs_by_default(monkeypatch):\n     _clear_auth_env(monkeypatch)"]},{"file":"tests/tools/test_url_safety.py","hunks":["@@ -152,6 +152,34 @@ def test_non_cgnat_100_allowed(self):\n             # 100.0.0.1 is a global IP, not in CGNAT range\n             assert is_safe_url(\"http://legit-host.example/\") is True\n \n+    def test_benchmark_ip_blocked_for_non_allowlisted_host(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://example.com/file.jpg\") is False\n+\n+    def test_qq_multimedia_hostname_allowed_with_benchmark_ip(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://multimedia.nt.qq.com.cn/download?id=123\") is True\n+\n+    def test_qq_multimedia_hostname_exception_is_exact_match(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"https://sub.multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n+    def test_qq_multimedia_hostname_exception_requires_https(self):\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"198.18.0.23\", 0)),\n+        ]):\n+            assert is_safe_url(\"http://multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n+    def test_qq_multimedia_hostname_dns_failure_still_blocked(self):\n+        with patch(\"socket.getaddrinfo\", side_effect=socket.gaierror(\"Name resolution failed\")):\n+            assert is_safe_url(\"https://multimedia.nt.qq.com.cn/download?id=123\") is False\n+\n \n class TestIsBlockedIp:\n     \"\"\"Direct tests for the _is_blocked_ip helper.\"\"\"","@@ -159,7 +187,7 @@ class TestIsBlockedIp:\n     @pytest.mark.parametrize(\"ip_str\", [\n         \"127.0.0.1\", \"10.0.0.1\", \"172.16.0.1\", \"192.168.1.1\",\n         \"169.254.169.254\", \"0.0.0.0\", \"224.0.0.1\", \"255.255.255.255\",\n-        \"100.64.0.1\", \"100.100.100.100\", \"100.127.255.254\",\n+        \"100.64.0.1\", \"100.100.100.100\", \"100.127.255.254\", \"198.18.0.23\",\n         \"::1\", \"fe80::1\", \"fc00::1\", \"fd12::1\", \"ff02::1\",\n         \"::ffff:127.0.0.1\", \"::ffff:169.254.169.254\",\n     ])"]},{"file":"tools/url_safety.py","hunks":["@@ -29,6 +29,13 @@\n     \"metadata.goog\",\n })\n \n+# Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs.\n+# This is intentionally narrow: QQ media downloads can legitimately resolve\n+# to 198.18.0.0/15 behind local proxy/benchmark infrastructure.\n+_TRUSTED_PRIVATE_IP_HOSTS = frozenset({\n+    \"multimedia.nt.qq.com.cn\",\n+})\n+\n # 100.64.0.0/10 (CGNAT / Shared Address Space, RFC 6598) is NOT covered by\n # ipaddress.is_private — it returns False for both is_private and is_global.\n # Must be blocked explicitly. Used by carrier-grade NAT, Tailscale/WireGuard","@@ -48,6 +55,11 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:\n     return False\n \n \n+def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool:\n+    \"\"\"Return True when a trusted HTTPS hostname may bypass IP-class blocking.\"\"\"\n+    return scheme == \"https\" and hostname in _TRUSTED_PRIVATE_IP_HOSTS\n+\n+\n def is_safe_url(url: str) -> bool:\n     \"\"\"Return True if the URL target is not a private/internal address.\n ","@@ -56,7 +68,8 @@ def is_safe_url(url: str) -> bool:\n     \"\"\"\n     try:\n         parsed = urlparse(url)\n-        hostname = (parsed.hostname or \"\").strip().lower()\n+        hostname = (parsed.hostname or \"\").strip().lower().rstrip(\".\")\n+        scheme = (parsed.scheme or \"\").strip().lower()\n         if not hostname:\n             return False\n ","@@ -65,6 +78,8 @@ def is_safe_url(url: str) -> bool:\n             logger.warning(\"Blocked request to internal hostname: %s\", hostname)\n             return False\n \n+        allow_private_ip = _allows_private_ip_resolution(hostname, scheme)\n+\n         # Try to resolve and check IP\n         try:\n             addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)","@@ -81,13 +96,19 @@ def is_safe_url(url: str) -> bool:\n             except ValueError:\n                 continue\n \n-            if _is_blocked_ip(ip):\n+            if not allow_private_ip and _is_blocked_ip(ip):\n                 logger.warning(\n                     \"Blocked request to private/internal address: %s -> %s\",\n                     hostname, ip_str,\n                 )\n                 return False\n \n+        if allow_private_ip:\n+            logger.debug(\n+                \"Allowing trusted hostname despite private/internal resolution: %s\",\n+                hostname,\n+            )\n+\n         return True\n \n     except Exception as exc:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}