{"repo":"NousResearch/hermes-agent","public_id":"5037","display_label":"DingTalk adapter compatibility fixes and reconnect issues","summary":"These issues and PRs address DingTalk adapter breakage caused by incompatible changes in the dingtalk-stream SDK (async API, CallbackMessage format, missing attributes) across versions 0.20–0.24+, plus a recurring reconnection storm or infinite loop problem in stream mode. Multiple overlapping fix attempts and code review requests for the same underlying problems are represented.","subsystem":"gateway","counts":{"members":15,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":8,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11471},"resolution":{"state":"partially_resolved","by":"pr-121768"},"freshness":{"state":"verified","last_verified_at":"2026-09-24T17:00:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11809523809523809},"fix_edges":[{"issue_number":9752,"pr_number":11471,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24851,"pr_number":121768,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5037,"title":"fix: DingTalk platform adapter has multiple bugs preventing message processing","url":null,"state":"closed"},{"kind":"issue","number":6986,"title":"[Bug] DingTalk adapter has multiple bugs preventing message processing (fix available in PR #5038)","url":null,"state":"closed"},{"kind":"issue","number":8811,"title":"[Fix #8345]: Async Handling, Message Parsing & Domain Validation","url":null,"state":"closed"},{"kind":"issue","number":8815,"title":"Request for Code Review Acceleration - PR #8345","url":null,"state":"closed"},{"kind":"issue","number":8816,"title":"Code Review Request for PR #8345","url":null,"state":"closed"},{"kind":"issue","number":9752,"title":"[Bug]: DingTalk adapter broken with dingtalk-stream >= 0.20 (async process/start)","url":null,"state":"closed"},{"kind":"issue","number":24851,"title":"DingTalk Stream Mode: reconnection storm causes gateway to hang","url":null,"state":"closed"},{"kind":"issue","number":31330,"title":"[Setup]: DingTalk adapter incompatible with dingtalk-stream 0.24.x (`_IncomingHandler` missing `raw_process`)","url":null,"state":"closed"},{"kind":"pr","number":9764,"title":"fix(gateway): DingTalk adapter compat with dingtalk-stream >= 0.20","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11471","number":11471},{"kind":"pr","value":"#11257","number":11257}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":11471,"title":"fix(dingtalk): support dingtalk-stream 0.24+ SDK (async process, CallbackMessage, oapi webhooks, TextContent)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/dingtalk.py","scripts/release.py","tests/gateway/test_dingtalk.py"],"fix_hunks":[{"file":"gateway/platforms/dingtalk.py","hunks":["@@ -54,7 +54,7 @@\n MAX_MESSAGE_LENGTH = 20000\n RECONNECT_BACKOFF = [2, 5, 10, 30, 60]\n _SESSION_WEBHOOKS_MAX = 500\n-_DINGTALK_WEBHOOK_RE = re.compile(r'^https://api\\.dingtalk\\.com/')\n+_DINGTALK_WEBHOOK_RE = re.compile(r'^https://(?:api|oapi)\\.dingtalk\\.com/')\n \n \n def check_dingtalk_requirements() -> bool:","@@ -128,12 +128,12 @@ async def connect(self) -> bool:\n             return False\n \n     async def _run_stream(self) -> None:\n-        \"\"\"Run the blocking stream client with auto-reconnection.\"\"\"\n+        \"\"\"Run the stream client with auto-reconnection.\"\"\"\n         backoff_idx = 0\n         while self._running:\n             try:\n                 logger.debug(\"[%s] Starting stream client...\", self.name)\n-                await asyncio.to_thread(self._stream_client.start)\n+                await self._stream_client.start()\n             except asyncio.CancelledError:\n                 return\n             except Exception as e:","@@ -238,18 +238,35 @@ async def _on_message(self, message: \"ChatbotMessage\") -> None:\n \n     @staticmethod\n     def _extract_text(message: \"ChatbotMessage\") -> str:\n-        \"\"\"Extract plain text from a DingTalk chatbot message.\"\"\"\n-        text = getattr(message, \"text\", None) or \"\"\n-        if isinstance(text, dict):\n-            content = text.get(\"content\", \"\").strip()\n-        else:\n-            content = str(text).strip()\n-\n-        # Fall back to rich text if present\n+        \"\"\"Extract plain text from a DingTalk chatbot message.\n+\n+        Handles both legacy and current dingtalk-stream SDK payload shapes:\n+          * legacy: ``message.text`` was a dict ``{\"content\": \"...\"}``\n+          * >= 0.20: ``message.text`` is a ``TextContent`` dataclass whose\n+            ``__str__`` returns ``\"TextContent(content=...)\"`` — never fall\n+            back to ``str(text)`` without extracting ``.content`` first.\n+          * rich text moved from ``message.rich_text`` (list) to\n+            ``message.rich_text_content.rich_text_list`` (list of dicts).\n+        \"\"\"\n+        text = getattr(message, \"text\", None)\n+        content = \"\"\n+        if text is not None:\n+            if isinstance(text, dict):\n+                content = (text.get(\"content\") or \"\").strip()\n+            elif hasattr(text, \"content\"):\n+                content = str(text.content or \"\").strip()\n+            else:\n+                content = str(text).strip()\n+\n         if not content:\n-            rich_text = getattr(message, \"rich_text\", None)\n-            if rich_text and isinstance(rich_text, list):\n-                parts = [item[\"text\"] for item in rich_text\n+            rich_list = None\n+            rtc = getattr(message, \"rich_text_content\", None)\n+            if rtc is not None and hasattr(rtc, \"rich_text_list\"):\n+                rich_list = rtc.rich_text_list\n+            if rich_list is None:\n+                rich_list = getattr(message, \"rich_text\", None)\n+            if rich_list and isinstance(rich_list, list):\n+                parts = [item[\"text\"] for item in rich_list\n                          if isinstance(item, dict) and item.get(\"text\")]\n                 content = \" \".join(parts).strip()\n         return content","@@ -314,19 +331,16 @@ def __init__(self, adapter: DingTalkAdapter, loop: asyncio.AbstractEventLoop):\n         self._adapter = adapter\n         self._loop = loop\n \n-    def process(self, message: \"ChatbotMessage\"):\n-        \"\"\"Called by dingtalk-stream in its thread when a message arrives.\n+    async def process(self, callback_message):\n+        \"\"\"Called by dingtalk-stream when a message arrives.\n \n-        Schedules the async handler on the main event loop.\n+        dingtalk-stream >= 0.24 passes a CallbackMessage whose `.data` contains\n+        the chatbot payload. Convert it to ChatbotMessage and await the adapter\n+        handler directly on the main event loop.\n         \"\"\"\n-        loop = self._loop\n-        if loop is None or loop.is_closed():\n-            logger.error(\"[DingTalk] Event loop unavailable, cannot dispatch message\")\n-            return dingtalk_stream.AckMessage.STATUS_OK, \"OK\"\n-\n-        future = asyncio.run_coroutine_threadsafe(self._adapter._on_message(message), loop)\n         try:\n-            future.result(timeout=60)\n+            chatbot_msg = ChatbotMessage.from_dict(callback_message.data)\n+            await self._adapter._on_message(chatbot_msg)\n         except Exception:\n             logger.exception(\"[DingTalk] Error processing incoming message\")\n "]},{"file":"scripts/release.py","hunks":["@@ -82,6 +82,7 @@\n     \"[redacted:email]\": \"aydnOktay\",\n     \"[redacted:email]\": \"Farukest\",\n     \"[redacted:email]\": \"rovle\",\n+    \"[redacted:email]\": \"kevinskysunny\",\n     \"[redacted:email]\": \"teyrebaz33\",\n     \"[redacted:email]\": \"Ruzzgar\",\n     \"[redacted:email]\": \"alireza78a\","]},{"file":"tests/gateway/test_dingtalk.py","hunks":["@@ -273,3 +273,133 @@ class TestPlatformEnum:\n \n     def test_dingtalk_in_platform_enum(self):\n         assert Platform.DINGTALK.value == \"dingtalk\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# SDK compatibility regression tests (dingtalk-stream >= 0.20 / 0.24)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestWebhookDomainAllowlist:\n+    \"\"\"Guard the webhook origin allowlist against regression.\n+\n+    The SDK started returning reply webhooks on ``oapi.dingtalk.com`` in\n+    addition to ``api.dingtalk.com``. Both must be accepted, and hostile\n+    lookalikes must still be rejected (SSRF defence-in-depth).\n+    \"\"\"\n+\n+    def test_api_domain_accepted(self):\n+        from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE\n+        assert _DINGTALK_WEBHOOK_RE.match(\n+            \"https://api.dingtalk.com/robot/send?access_token=x\"\n+        )\n+\n+    def test_oapi_domain_accepted(self):\n+        from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE\n+        assert _DINGTALK_WEBHOOK_RE.match(\n+            \"https://oapi.dingtalk.com/robot/send?access_token=x\"\n+        )\n+\n+    def test_http_rejected(self):\n+        from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE\n+        assert not _DINGTALK_WEBHOOK_RE.match(\"http://api.dingtalk.com/robot/send\")\n+\n+    def test_suffix_attack_rejected(self):\n+        from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE\n+        assert not _DINGTALK_WEBHOOK_RE.match(\n+            \"https://api.dingtalk.com.evil.example/\"\n+        )\n+\n+    def test_unsanctioned_subdomain_rejected(self):\n+        from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE\n+        # Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through\n+        assert not _DINGTALK_WEBHOOK_RE.match(\"https://eapi.dingtalk.com/robot/send\")\n+\n+\n+class TestHandlerProcessIsAsync:\n+    \"\"\"dingtalk-stream >= 0.20 requires ``process`` to be a coroutine.\"\"\"\n+\n+    def test_process_is_coroutine_function(self):\n+        from gateway.platforms.dingtalk import _IncomingHandler\n+        assert asyncio.iscoroutinefunction(_IncomingHandler.process)\n+\n+\n+class TestExtractText:\n+    \"\"\"_extract_text must handle both legacy and current SDK payload shapes.\n+\n+    Before SDK 0.20 ``message.text`` was a ``dict`` with a ``content`` key.\n+    From 0.20 onward it is a ``TextContent`` dataclass whose ``__str__``\n+    returns ``\"TextContent(content=...)\"`` — falling back to ``str(text)``\n+    leaks that repr into the agent's input.\n+    \"\"\"\n+\n+    def test_text_as_dict_legacy(self):\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+        msg = MagicMock()\n+        msg.text = {\"content\": \"hello world\"}\n+        msg.rich_text_content = None\n+        msg.rich_text = None\n+        assert DingTalkAdapter._extract_text(msg) == \"hello world\"\n+\n+    def test_text_as_textcontent_object(self):\n+        \"\"\"SDK >= 0.20 shape: object with ``.content`` attribute.\"\"\"\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+\n+        class FakeTextContent:\n+            content = \"hello from new sdk\"\n+\n+            def __str__(self):  # mimic real SDK repr\n+                return f\"TextContent(content={self.content})\"\n+\n+        msg = MagicMock()\n+        msg.text = FakeTextContent()\n+        msg.rich_text_content = None\n+        msg.rich_text = None\n+        result = DingTalkAdapter._extract_text(msg)\n+        assert result == \"hello from new sdk\"\n+        assert \"TextContent(\" not in result\n+\n+    def test_text_content_attr_with_empty_string(self):\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+\n+        class FakeTextContent:\n+            content = \"\"\n+\n+        msg = MagicMock()\n+        msg.text = FakeTextContent()\n+        msg.rich_text_content = None\n+        msg.rich_text = None\n+        assert DingTalkAdapter._extract_text(msg) == \"\"\n+\n+    def test_rich_text_content_new_shape(self):\n+        \"\"\"SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``.\"\"\"\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+\n+        class FakeRichText:\n+            rich_text_list = [{\"text\": \"hello \"}, {\"text\": \"world\"}]\n+\n+        msg = MagicMock()\n+        msg.text = None\n+        msg.rich_text_content = FakeRichText()\n+        msg.rich_text = None\n+        result = DingTalkAdapter._extract_text(msg)\n+        assert \"hello\" in result and \"world\" in result\n+\n+    def test_rich_text_legacy_shape(self):\n+        \"\"\"Legacy ``message.rich_text`` list remains supported.\"\"\"\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+        msg = MagicMock()\n+        msg.text = None\n+        msg.rich_text_content = None\n+        msg.rich_text = [{\"text\": \"legacy \"}, {\"text\": \"rich\"}]\n+        result = DingTalkAdapter._extract_text(msg)\n+        assert \"legacy\" in result and \"rich\" in result\n+\n+    def test_empty_message(self):\n+        from gateway.platforms.dingtalk import DingTalkAdapter\n+        msg = MagicMock()\n+        msg.text = None\n+        msg.rich_text_content = None\n+        msg.rich_text = None\n+        assert DingTalkAdapter._extract_text(msg) == \"\"\n+\n"]}]},{"kind":"pr","number":24866,"title":"fix(dingtalk): add circuit breaker to prevent infinite reconnect storm","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"857ad4b954","number":0},{"kind":"pr","value":"#24851","number":24851},{"kind":"pr","value":"#121768","number":121768}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":24868,"title":"fix(dingtalk): trip reconnect loop after 5 consecutive startup failures (#24851)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":24881,"title":"fix(gateway): add circuit breaker to DingTalk reconnect loop to prevent log storm (#24851)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50621","number":50621},{"kind":"pr","value":"#50621","number":50621},{"kind":"pr","value":"#50621","number":50621},{"kind":"pr","value":"#24851","number":24851}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":50621,"title":"fix(dingtalk): add circuit breaker to stream reconnect loop to prevent log storm (#24851)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#121768","number":121768}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":121768,"title":"fix(dingtalk): stop the stream reconnect log storm and fail closed on a websockets/SDK mismatch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/dingtalk/adapter.py","tests/gateway/test_dingtalk_reconnect_circuit_breaker.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}