{"repo":"NousResearch/hermes-agent","public_id":"11463","display_label":"DingTalk integration fixes and feature updates","summary":"Multiple fixes and improvements to the DingTalk integration, covering SDK compatibility, callback payload handling, message processing, session webhook fallback, and access-gating features. Work spans bug fixes, async processing changes, and parity with other platform integrations.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11564},"resolution":{"state":"resolved","by":"pr-11605"},"freshness":{"state":"stale","last_verified_at":"2026-04-17T13:26:19+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":11463,"pr_number":11564,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":11463,"pr_number":11605,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11463,"title":"[Bug]: dingtalk channle callback error","url":null,"state":"closed"},{"kind":"pr","number":11509,"title":"fix(dingtalk): convert callback payloads before dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11471","number":11471},{"kind":"pr","value":"#11463","number":11463}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":11518,"title":"fix(dingtalk): fire-and-forget message processing & session_webhook fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11605","number":11605},{"kind":"pr","value":"#11564","number":11564},{"kind":"pr","value":"#11463","number":11463}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11564,"title":"feat(dingtalk): add require_mention + allowed_users gating (parity with Slack/Telegram/Discord)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/dingtalk.py","scripts/release.py","tests/gateway/test_dingtalk.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -677,6 +677,24 @@ def load_gateway_config() -> GatewayConfig:\n                         frc = \",\".join(str(v) for v in frc)\n                     os.environ[\"WHATSAPP_FREE_RESPONSE_CHATS\"] = str(frc)\n \n+            # DingTalk settings → env vars (env vars take precedence)\n+            dingtalk_cfg = yaml_cfg.get(\"dingtalk\", {})\n+            if isinstance(dingtalk_cfg, dict):\n+                if \"require_mention\" in dingtalk_cfg and not os.getenv(\"DINGTALK_REQUIRE_MENTION\"):\n+                    os.environ[\"DINGTALK_REQUIRE_MENTION\"] = str(dingtalk_cfg[\"require_mention\"]).lower()\n+                if \"mention_patterns\" in dingtalk_cfg and not os.getenv(\"DINGTALK_MENTION_PATTERNS\"):\n+                    os.environ[\"DINGTALK_MENTION_PATTERNS\"] = json.dumps(dingtalk_cfg[\"mention_patterns\"])\n+                frc = dingtalk_cfg.get(\"free_response_chats\")\n+                if frc is not None and not os.getenv(\"DINGTALK_FREE_RESPONSE_CHATS\"):\n+                    if isinstance(frc, list):\n+                        frc = \",\".join(str(v) for v in frc)\n+                    os.environ[\"DINGTALK_FREE_RESPONSE_CHATS\"] = str(frc)\n+                allowed = dingtalk_cfg.get(\"allowed_users\")\n+                if allowed is not None and not os.getenv(\"DINGTALK_ALLOWED_USERS\"):\n+                    if isinstance(allowed, list):\n+                        allowed = \",\".join(str(v) for v in allowed)\n+                    os.environ[\"DINGTALK_ALLOWED_USERS\"] = str(allowed)\n+\n             # Matrix settings → env vars (env vars take precedence)\n             matrix_cfg = yaml_cfg.get(\"matrix\", {})\n             if isinstance(matrix_cfg, dict):"]},{"file":"gateway/platforms/dingtalk.py","hunks":["@@ -12,18 +12,27 @@\n     platforms:\n       dingtalk:\n         enabled: true\n+        # Optional group-chat gating (mirrors Slack/Telegram/Discord):\n+        require_mention: true            # or DINGTALK_REQUIRE_MENTION env var\n+        # free_response_chats:           # conversations that skip require_mention\n+        #   - cidABC==\n+        # mention_patterns:              # regex wake-words (e.g. Chinese bot names)\n+        #   - \"^小马\"\n+        # allowed_users:                 # staff_id or sender_id list; \"*\" = any\n+        #   - \"manager1234\"\n         extra:\n           client_id: \"your-app-key\"      # or DINGTALK_CLIENT_ID env var\n           client_secret: \"[redacted:secret-assignment]\"   # or DINGTALK_CLIENT_SECRET env var\n \"\"\"\n \n import asyncio\n+import json\n import logging\n import os\n import re\n import uuid\n from datetime import datetime, timezone\n-from typing import Any, Dict, Optional\n+from typing import Any, Dict, List, Optional, Set\n \n try:\n     import dingtalk_stream","@@ -92,6 +101,10 @@ def __init__(self, config: PlatformConfig):\n         # Map chat_id -> session_webhook for reply routing\n         self._session_webhooks: Dict[str, str] = {}\n \n+        # Group-chat gating (mirrors Slack/Telegram/Discord/WhatsApp conventions)\n+        self._mention_patterns: List[re.Pattern] = self._compile_mention_patterns()\n+        self._allowed_users: Set[str] = self._load_allowed_users()\n+\n     # -- Connection lifecycle -----------------------------------------------\n \n     async def connect(self) -> bool:","@@ -178,6 +191,118 @@ async def disconnect(self) -> None:\n         self._dedup.clear()\n         logger.info(\"[%s] Disconnected\", self.name)\n \n+    # -- Group gating --------------------------------------------------------\n+\n+    def _dingtalk_require_mention(self) -> bool:\n+        \"\"\"Return whether group chats should require an explicit bot trigger.\"\"\"\n+        configured = self.config.extra.get(\"require_mention\")\n+        if configured is not None:\n+            if isinstance(configured, str):\n+                return configured.lower() in (\"true\", \"1\", \"yes\", \"on\")\n+            return bool(configured)\n+        return os.getenv(\"DINGTALK_REQUIRE_MENTION\", \"false\").lower() in (\"true\", \"1\", \"yes\", \"on\")\n+\n+    def _dingtalk_free_response_chats(self) -> Set[str]:\n+        raw = self.config.extra.get(\"free_response_chats\")\n+        if raw is None:\n+            raw = os.getenv(\"DINGTALK_FREE_RESPONSE_CHATS\", \"\")\n+        if isinstance(raw, list):\n+            return {str(part).strip() for part in raw if str(part).strip()}\n+        return {part.strip() for part in str(raw).split(\",\") if part.strip()}\n+\n+    def _compile_mention_patterns(self) -> List[re.Pattern]:\n+        \"\"\"Compile optional regex wake-word patterns for group triggers.\"\"\"\n+        patterns = self.config.extra.get(\"mention_patterns\") if self.config.extra else None\n+        if patterns is None:\n+            raw = os.getenv(\"DINGTALK_MENTION_PATTERNS\", \"\").strip()\n+            if raw:\n+                try:\n+                    loaded = json.loads(raw)\n+                except Exception:\n+                    loaded = [part.strip() for part in raw.splitlines() if part.strip()]\n+                    if not loaded:\n+                        loaded = [part.strip() for part in raw.split(\",\") if part.strip()]\n+                patterns = loaded\n+\n+        if patterns is None:\n+            return []\n+        if isinstance(patterns, str):\n+            patterns = [patterns]\n+        if not isinstance(patterns, list):\n+            logger.warning(\n+                \"[%s] dingtalk mention_patterns must be a list or string; got %s\",\n+                self.name,\n+                type(patterns).__name__,\n+            )\n+            return []\n+\n+        compiled: List[re.Pattern] = []\n+        for pattern in patterns:\n+            if not isinstance(pattern, str) or not pattern.strip():\n+                continue\n+            try:\n+                compiled.append(re.compile(pattern, re.IGNORECASE))\n+            except re.error as exc:\n+                logger.warning(\"[%s] Invalid DingTalk mention pattern %r: %s\", self.name, pattern, exc)\n+        if compiled:\n+            logger.info(\"[%s] Loaded %d DingTalk mention pattern(s)\", self.name, len(compiled))\n+        return compiled\n+\n+    def _load_allowed_users(self) -> Set[str]:\n+        \"\"\"Load allowed-users list from config.extra or env var.\n+\n+        IDs are matched case-insensitively against the sender's ``staff_id`` and\n+        ``sender_id``. A wildcard ``*`` disables the check.\n+        \"\"\"\n+        raw = self.config.extra.get(\"allowed_users\") if self.config.extra else None\n+        if raw is None:\n+            raw = os.getenv(\"DINGTALK_ALLOWED_USERS\", \"\")\n+        if isinstance(raw, list):\n+            items = [str(part).strip() for part in raw if str(part).strip()]\n+        else:\n+            items = [part.strip() for part in str(raw).split(\",\") if part.strip()]\n+        return {item.lower() for item in items}\n+\n+    def _is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool:\n+        if not self._allowed_users or \"*\" in self._allowed_users:\n+            return True\n+        candidates = {(sender_id or \"\").lower(), (sender_staff_id or \"\").lower()}\n+        candidates.discard(\"\")\n+        return bool(candidates & self._allowed_users)\n+\n+    def _message_mentions_bot(self, message: \"ChatbotMessage\") -> bool:\n+        \"\"\"True if the bot was @-mentioned in a group message.\n+\n+        dingtalk-stream sets ``is_in_at_list`` on the incoming ChatbotMessage\n+        when the bot is addressed via @-mention.\n+        \"\"\"\n+        return bool(getattr(message, \"is_in_at_list\", False))\n+\n+    def _message_matches_mention_patterns(self, text: str) -> bool:\n+        if not text or not self._mention_patterns:\n+            return False\n+        return any(pattern.search(text) for pattern in self._mention_patterns)\n+\n+    def _should_process_message(self, message: \"ChatbotMessage\", text: str, is_group: bool, chat_id: str) -> bool:\n+        \"\"\"Apply DingTalk group trigger rules.\n+\n+        DMs remain unrestricted (subject to ``allowed_users`` which is enforced\n+        earlier). Group messages are accepted when:\n+        - the chat is explicitly allowlisted in ``free_response_chats``\n+        - ``require_mention`` is disabled\n+        - the bot is @mentioned (``is_in_at_list``)\n+        - the text matches a configured regex wake-word pattern\n+        \"\"\"\n+        if not is_group:\n+            return True\n+        if chat_id and chat_id in self._dingtalk_free_response_chats():\n+            return True\n+        if not self._dingtalk_require_mention():\n+            return True\n+        if self._message_mentions_bot(message):\n+            return True\n+        return self._message_matches_mention_patterns(text)\n+\n     # -- Inbound message processing -----------------------------------------\n \n     async def _on_message(self, message: \"ChatbotMessage\") -> None:","@@ -203,6 +328,22 @@ async def _on_message(self, message: \"ChatbotMessage\") -> None:\n         chat_id = conversation_id or sender_id\n         chat_type = \"group\" if is_group else \"dm\"\n \n+        # Allowed-users gate (applies to both DM and group)\n+        if not self._is_user_allowed(sender_id, sender_staff_id):\n+            logger.debug(\n+                \"[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s\",\n+                self.name, sender_staff_id, sender_id,\n+            )\n+            return\n+\n+        # Group mention/pattern gate\n+        if not self._should_process_message(message, text, is_group, chat_id):\n+            logger.debug(\n+                \"[%s] Dropping group message that failed mention gate message_id=%s chat_id=%s\",\n+                self.name, msg_id, chat_id,\n+            )\n+            return\n+\n         # Store session webhook for reply routing (validate origin to prevent SSRF)\n         session_webhook = getattr(message, \"session_webhook\", None) or \"\"\n         if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook):"]},{"file":"scripts/release.py","hunks":["@@ -84,6 +84,7 @@\n     \"[redacted:email]\": \"Farukest\",\n     \"[redacted:email]\": \"rovle\",\n     \"[redacted:email]\": \"kevinskysunny\",\n+    \"[redacted:email]\": \"yule975\",\n     \"[redacted:email]\": \"teyrebaz33\",\n     \"[redacted:email]\": \"Ruzzgar\",\n     \"[redacted:email]\": \"alireza78a\","]},{"file":"tests/gateway/test_dingtalk.py","hunks":["@@ -421,3 +421,157 @@ def test_empty_message(self):\n         msg.rich_text = None\n         assert DingTalkAdapter._extract_text(msg) == \"\"\n \n+\n+# ---------------------------------------------------------------------------\n+# Group gating — require_mention + allowed_users (parity with other platforms)\n+# ---------------------------------------------------------------------------\n+\n+\n+def _make_gating_adapter(monkeypatch, *, extra=None, env=None):\n+    \"\"\"Build a DingTalkAdapter with only the gating fields populated.\n+\n+    Clears every DINGTALK_* gating env var before applying the caller's\n+    overrides so individual tests stay isolated.\n+    \"\"\"\n+    for key in (\n+        \"DINGTALK_REQUIRE_MENTION\",\n+        \"DINGTALK_MENTION_PATTERNS\",\n+        \"DINGTALK_FREE_RESPONSE_CHATS\",\n+        \"DINGTALK_ALLOWED_USERS\",\n+    ):\n+        monkeypatch.delenv(key, raising=False)\n+    for key, value in (env or {}).items():\n+        monkeypatch.setenv(key, value)\n+    from gateway.platforms.dingtalk import DingTalkAdapter\n+    return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))\n+\n+\n+class TestAllowedUsersGate:\n+\n+    def test_empty_allowlist_allows_everyone(self, monkeypatch):\n+        adapter = _make_gating_adapter(monkeypatch)\n+        assert adapter._is_user_allowed(\"anyone\", \"any-staff\") is True\n+\n+    def test_wildcard_allowlist_allows_everyone(self, monkeypatch):\n+        adapter = _make_gating_adapter(monkeypatch, extra={\"allowed_users\": [\"*\"]})\n+        assert adapter._is_user_allowed(\"anyone\", \"any-staff\") is True\n+\n+    def test_matches_sender_id_case_insensitive(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"allowed_users\": [\"SenderABC\"]}\n+        )\n+        assert adapter._is_user_allowed(\"senderabc\", \"\") is True\n+\n+    def test_matches_staff_id(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"allowed_users\": [\"staff_1234\"]}\n+        )\n+        assert adapter._is_user_allowed(\"\", \"staff_1234\") is True\n+\n+    def test_rejects_unknown_user(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"allowed_users\": [\"staff_1234\"]}\n+        )\n+        assert adapter._is_user_allowed(\"other-sender\", \"other-staff\") is False\n+\n+    def test_env_var_csv_populates_allowlist(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, env={\"DINGTALK_ALLOWED_USERS\": \"alice,bob,carol\"}\n+        )\n+        assert adapter._is_user_allowed(\"alice\", \"\") is True\n+        assert adapter._is_user_allowed(\"dave\", \"\") is False\n+\n+\n+class TestMentionPatterns:\n+\n+    def test_empty_patterns_list(self, monkeypatch):\n+        adapter = _make_gating_adapter(monkeypatch)\n+        assert adapter._mention_patterns == []\n+        assert adapter._message_matches_mention_patterns(\"anything\") is False\n+\n+    def test_pattern_matches_text(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"mention_patterns\": [\"^hermes\"]}\n+        )\n+        assert adapter._message_matches_mention_patterns(\"hermes please help\") is True\n+        assert adapter._message_matches_mention_patterns(\"please hermes help\") is False\n+\n+    def test_pattern_is_case_insensitive(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"mention_patterns\": [\"^hermes\"]}\n+        )\n+        assert adapter._message_matches_mention_patterns(\"HERMES help\") is True\n+\n+    def test_invalid_regex_is_skipped_not_raised(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch,\n+            extra={\"mention_patterns\": [\"[unclosed\", \"^valid\"]},\n+        )\n+        # Invalid pattern dropped, valid one kept\n+        assert len(adapter._mention_patterns) == 1\n+        assert adapter._message_matches_mention_patterns(\"valid trigger\") is True\n+\n+    def test_env_var_json_populates_patterns(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch,\n+            env={\"DINGTALK_MENTION_PATTERNS\": '[\"^bot\", \"^assistant\"]'},\n+        )\n+        assert len(adapter._mention_patterns) == 2\n+        assert adapter._message_matches_mention_patterns(\"bot ping\") is True\n+\n+    def test_env_var_newline_fallback_when_not_json(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch,\n+            env={\"DINGTALK_MENTION_PATTERNS\": \"^bot\\n^assistant\"},\n+        )\n+        assert len(adapter._mention_patterns) == 2\n+\n+\n+class TestShouldProcessMessage:\n+\n+    def test_dm_always_accepted(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"require_mention\": True}\n+        )\n+        msg = MagicMock(is_in_at_list=False)\n+        assert adapter._should_process_message(msg, \"hi\", is_group=False, chat_id=\"dm1\") is True\n+\n+    def test_group_rejected_when_require_mention_and_no_trigger(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"require_mention\": True}\n+        )\n+        msg = MagicMock(is_in_at_list=False)\n+        assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp1\") is False\n+\n+    def test_group_accepted_when_require_mention_disabled(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"require_mention\": False}\n+        )\n+        msg = MagicMock(is_in_at_list=False)\n+        assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp1\") is True\n+\n+    def test_group_accepted_when_bot_is_mentioned(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch, extra={\"require_mention\": True}\n+        )\n+        msg = MagicMock(is_in_at_list=True)\n+        assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp1\") is True\n+\n+    def test_group_accepted_when_text_matches_wake_word(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch,\n+            extra={\"require_mention\": True, \"mention_patterns\": [\"^hermes\"]},\n+        )\n+        msg = MagicMock(is_in_at_list=False)\n+        assert adapter._should_process_message(msg, \"hermes help\", is_group=True, chat_id=\"grp1\") is True\n+\n+    def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):\n+        adapter = _make_gating_adapter(\n+            monkeypatch,\n+            extra={\"require_mention\": True, \"free_response_chats\": [\"grp1\"]},\n+        )\n+        msg = MagicMock(is_in_at_list=False)\n+        assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp1\") is True\n+        # Different group still blocked\n+        assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp2\") is False\n+\n"]}]},{"kind":"pr","number":11605,"title":"fix(dingtalk): get_connected_platforms + fire-and-forget processing + null-toolsets guard","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/dingtalk.py","hermes_cli/tools_config.py","scripts/release.py","tests/gateway/test_config.py","tests/gateway/test_dingtalk.py","tests/hermes_cli/test_tools_config.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -307,6 +307,14 @@ def get_connected_platforms(self) -> List[Platform]:\n             # QQBot uses extra dict for app credentials\n             elif platform == Platform.QQBOT and config.extra.get(\"app_id\") and config.extra.get(\"client_secret\"):\n                 connected.append(platform)\n+            # DingTalk uses client_id/client_secret from config.extra or env vars\n+            elif platform == Platform.DINGTALK and (\n+                config.extra.get(\"client_id\") or os.getenv(\"DINGTALK_CLIENT_ID\")\n+            ) and (\n+                config.extra.get(\"client_secret\") or os.getenv(\"DINGTALK_CLIENT_SECRET\")\n+            ):\n+                connected.append(platform)\n+        \n         return connected\n     \n     def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:"]},{"file":"gateway/platforms/dingtalk.py","hunks":["@@ -483,13 +483,39 @@ async def process(self, callback_message):\n         \"\"\"Called by dingtalk-stream when a message arrives.\n \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+        the chatbot payload. Convert it to ChatbotMessage via\n+        ``ChatbotMessage.from_dict()``.\n+\n+        Message processing is dispatched as a background task so that this\n+        method returns the ACK immediately — blocking here would prevent the\n+        SDK from sending heartbeats, eventually causing a disconnect.\n         \"\"\"\n         try:\n-            chatbot_msg = ChatbotMessage.from_dict(callback_message.data)\n-            await self._adapter._on_message(chatbot_msg)\n+            data = callback_message.data\n+            chatbot_msg = ChatbotMessage.from_dict(data)\n+\n+            # Ensure session_webhook is populated even if the SDK's\n+            # from_dict() did not map it (field name mismatch across\n+            # SDK versions).\n+            if not getattr(chatbot_msg, \"session_webhook\", None):\n+                webhook = (\n+                    data.get(\"sessionWebhook\")\n+                    or data.get(\"session_webhook\")\n+                    or \"\"\n+                )\n+                if webhook:\n+                    chatbot_msg.session_webhook = webhook\n+\n+            # Fire-and-forget: return ACK immediately, process in background.\n+            asyncio.create_task(self._safe_on_message(chatbot_msg))\n         except Exception:\n-            logger.exception(\"[DingTalk] Error processing incoming message\")\n+            logger.exception(\"[DingTalk] Error preparing incoming message\")\n \n         return dingtalk_stream.AckMessage.STATUS_OK, \"OK\"\n+\n+    async def _safe_on_message(self, chatbot_msg: \"ChatbotMessage\") -> None:\n+        \"\"\"Wrapper that catches exceptions from _on_message.\"\"\"\n+        try:\n+            await self._adapter._on_message(chatbot_msg)\n+        except Exception:\n+            logger.exception(\"[DingTalk] Error processing incoming message\")"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -512,7 +512,7 @@ def _get_platform_tools(\n     \"\"\"Resolve which individual toolset names are enabled for a platform.\"\"\"\n     from toolsets import resolve_toolset\n \n-    platform_toolsets = config.get(\"platform_toolsets\", {})\n+    platform_toolsets = config.get(\"platform_toolsets\") or {}\n     toolset_names = platform_toolsets.get(platform)\n \n     if toolset_names is None or not isinstance(toolset_names, list):"]},{"file":"scripts/release.py","hunks":["@@ -70,6 +70,7 @@\n     \"[redacted:email]\": \"nosleepcassette\",\n     \"[redacted:email]\": \"MestreY0d4-Uninter\",\n     \"[redacted:email]\": \"davetist\",\n+    \"[redacted:email]\": \"yyq4193\",\n     \"[redacted:email]\": \"Asunfly\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"asheriif\",","@@ -181,6 +182,7 @@\n     \"[redacted:email]\": \"jjovalle99\",\n     \"[redacted:email]\": \"Julientalbot\",\n     \"[redacted:email]\": \"kagura-agent\",\n+    \"[redacted:email]\": \"youngDoo\",\n     \"[redacted:email]\": \"kamil-gwozdz\",\n     \"[redacted:email]\": \"MustafaKara7\",\n     \"[redacted:email]\": \"kira-ariaki\","]},{"file":"tests/gateway/test_config.py","hunks":["@@ -71,6 +71,51 @@ def test_empty_platforms(self):\n         config = GatewayConfig()\n         assert config.get_connected_platforms() == []\n \n+    def test_dingtalk_recognised_via_extras(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DINGTALK: PlatformConfig(\n+                    enabled=True,\n+                    extra={\"client_id\": \"cid\", \"client_secret\": \"sec\"},\n+                ),\n+            },\n+        )\n+        assert Platform.DINGTALK in config.get_connected_platforms()\n+\n+    def test_dingtalk_recognised_via_env_vars(self, monkeypatch):\n+        \"\"\"DingTalk configured via env vars (no extras) should still be\n+        recognised as connected — covers the case where _apply_env_overrides\n+        hasn't populated extras yet.\"\"\"\n+        monkeypatch.setenv(\"DINGTALK_CLIENT_ID\", \"env_cid\")\n+        monkeypatch.setenv(\"DINGTALK_CLIENT_SECRET\", \"env_sec\")\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),\n+            },\n+        )\n+        assert Platform.DINGTALK in config.get_connected_platforms()\n+\n+    def test_dingtalk_missing_creds_not_connected(self, monkeypatch):\n+        monkeypatch.delenv(\"DINGTALK_CLIENT_ID\", raising=False)\n+        monkeypatch.delenv(\"DINGTALK_CLIENT_SECRET\", raising=False)\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),\n+            },\n+        )\n+        assert Platform.DINGTALK not in config.get_connected_platforms()\n+\n+    def test_dingtalk_disabled_not_connected(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DINGTALK: PlatformConfig(\n+                    enabled=False,\n+                    extra={\"client_id\": \"cid\", \"client_secret\": \"sec\"},\n+                ),\n+            },\n+        )\n+        assert Platform.DINGTALK not in config.get_connected_platforms()\n+\n \n class TestSessionResetPolicy:\n     def test_roundtrip(self):"]},{"file":"tests/gateway/test_dingtalk.py","hunks":["@@ -575,3 +575,109 @@ def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):\n         # Different group still blocked\n         assert adapter._should_process_message(msg, \"hi\", is_group=True, chat_id=\"grp2\") is False\n \n+\n+# ---------------------------------------------------------------------------\n+# _IncomingHandler.process — session_webhook extraction & fire-and-forget\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestIncomingHandlerProcess:\n+    \"\"\"Verify that _IncomingHandler.process correctly converts callback data\n+    and dispatches message processing as a background task (fire-and-forget)\n+    so the SDK ACK is returned immediately.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_process_extracts_session_webhook(self):\n+        \"\"\"session_webhook must be populated from callback data.\"\"\"\n+        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter\n+\n+        adapter = DingTalkAdapter(PlatformConfig(enabled=True))\n+        adapter._on_message = AsyncMock()\n+        handler = _IncomingHandler(adapter, asyncio.get_running_loop())\n+\n+        callback = MagicMock()\n+        callback.data = {\n+            \"msgtype\": \"text\",\n+            \"text\": {\"content\": \"hello\"},\n+            \"senderId\": \"user1\",\n+            \"conversationId\": \"conv1\",\n+            \"sessionWebhook\": \"https://oapi.dingtalk.com/robot/sendBySession?session=abc\",\n+            \"msgId\": \"msg-001\",\n+        }\n+\n+        result = await handler.process(callback)\n+        # Should return ACK immediately (STATUS_OK = 200)\n+        assert result[0] == 200\n+\n+        # Let the background task run\n+        await asyncio.sleep(0.05)\n+\n+        # _on_message should have been called with a ChatbotMessage\n+        adapter._on_message.assert_called_once()\n+        chatbot_msg = adapter._on_message.call_args[0][0]\n+        assert chatbot_msg.session_webhook == \"https://oapi.dingtalk.com/robot/sendBySession?session=abc\"\n+\n+    @pytest.mark.asyncio\n+    async def test_process_fallback_session_webhook_when_from_dict_misses_it(self):\n+        \"\"\"If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK\n+        version mismatch), the handler should fall back to extracting it\n+        directly from the raw data dict.\"\"\"\n+        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter\n+\n+        adapter = DingTalkAdapter(PlatformConfig(enabled=True))\n+        adapter._on_message = AsyncMock()\n+        handler = _IncomingHandler(adapter, asyncio.get_running_loop())\n+\n+        callback = MagicMock()\n+        # Use a key that from_dict might not recognise in some SDK versions\n+        callback.data = {\n+            \"msgtype\": \"text\",\n+            \"text\": {\"content\": \"hi\"},\n+            \"senderId\": \"user2\",\n+            \"conversationId\": \"conv2\",\n+            \"session_webhook\": \"https://oapi.dingtalk.com/robot/sendBySession?session=def\",\n+            \"msgId\": \"msg-002\",\n+        }\n+\n+        await handler.process(callback)\n+        await asyncio.sleep(0.05)\n+\n+        adapter._on_message.assert_called_once()\n+        chatbot_msg = adapter._on_message.call_args[0][0]\n+        assert chatbot_msg.session_webhook == \"https://oapi.dingtalk.com/robot/sendBySession?session=def\"\n+\n+    @pytest.mark.asyncio\n+    async def test_process_returns_ack_immediately(self):\n+        \"\"\"process() must not block on _on_message — it should return\n+        the ACK tuple before the message is fully processed.\"\"\"\n+        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter\n+\n+        processing_started = asyncio.Event()\n+        processing_gate = asyncio.Event()\n+\n+        async def slow_on_message(msg):\n+            processing_started.set()\n+            await processing_gate.wait()  # Block until we release\n+\n+        adapter = DingTalkAdapter(PlatformConfig(enabled=True))\n+        adapter._on_message = slow_on_message\n+        handler = _IncomingHandler(adapter, asyncio.get_running_loop())\n+\n+        callback = MagicMock()\n+        callback.data = {\n+            \"msgtype\": \"text\",\n+            \"text\": {\"content\": \"test\"},\n+            \"senderId\": \"u\",\n+            \"conversationId\": \"c\",\n+            \"sessionWebhook\": \"https://oapi.dingtalk.com/x\",\n+            \"msgId\": \"m\",\n+        }\n+\n+        # process() should return immediately even though _on_message blocks\n+        result = await handler.process(callback)\n+        assert result[0] == 200\n+\n+        # Clean up: release the gate so the background task finishes\n+        processing_gate.set()\n+        await asyncio.sleep(0.05)\n+"]},{"file":"tests/hermes_cli/test_tools_config.py","hunks":["@@ -40,6 +40,19 @@ def test_get_platform_tools_preserves_explicit_empty_selection():\n     assert enabled == set()\n \n \n+def test_get_platform_tools_handles_null_platform_toolsets():\n+    \"\"\"YAML `platform_toolsets:` with no value parses as None — the old\n+    ``config.get(\"platform_toolsets\", {})`` pattern would then crash with\n+    ``NoneType has no attribute 'get'`` on the next line. Guard against that.\n+    \"\"\"\n+    config = {\"platform_toolsets\": None}\n+\n+    enabled = _get_platform_tools(config, \"cli\")\n+\n+    # Falls through to defaults instead of raising\n+    assert enabled\n+\n+\n def test_platform_toolset_summary_uses_explicit_platform_list():\n     config = {}\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}