# NousResearch/hermes-agent · DingTalk integration fixes and feature updates

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #11564. Verify it addresses your case.
- **Recommended:** read PR #11564 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-11605`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #11463: \[Bug\]: dingtalk channle callback error

## Prior & competing PRs

- **#11564: feat(dingtalk): add require_mention + allowed_users gating (parity with Slack/Telegram/Discord)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `gateway/platforms/dingtalk.py`, `scripts/release.py`, `tests/gateway/test_dingtalk.py`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -677,6 +677,24 @@ def load_gateway_config() -> GatewayConfig:
                             frc = ",".join(str(v) for v in frc)
                         os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
     
    +            # DingTalk settings → env vars (env vars take precedence)
    +            dingtalk_cfg = yaml_cfg.get("dingtalk", {})
    +            if isinstance(dingtalk_cfg, dict):
    +                if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
    +                    os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
    +                if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
    +                    os.environ["DINGTALK_MENTION_PATTERNS"] = json.dumps(dingtalk_cfg["mention_patterns"])
    +                frc = dingtalk_cfg.get("free_response_chats")
    +                if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
    +                    if isinstance(frc, list):
    +                        frc = ",".join(str(v) for v in frc)
    +                    os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
    +                allowed = dingtalk_cfg.get("allowed_users")
    +                if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
    +                    if isinstance(allowed, list):
    +                        allowed = ",".join(str(v) for v in allowed)
    +                    os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
    +
                 # Matrix settings → env vars (env vars take precedence)
                 matrix_cfg = yaml_cfg.get("matrix", {})
                 if isinstance(matrix_cfg, dict):
    ```
  - `gateway/platforms/dingtalk.py`

    ```diff
    @@ -12,18 +12,27 @@
         platforms:
           dingtalk:
             enabled: true
    +        # Optional group-chat gating (mirrors Slack/Telegram/Discord):
    +        require_mention: true            # or DINGTALK_REQUIRE_MENTION env var
    +        # free_response_chats:           # conversations that skip require_mention
    +        #   - cidABC==
    +        # mention_patterns:              # regex wake-words (e.g. Chinese bot names)
    +        #   - "^小马"
    +        # allowed_users:                 # staff_id or sender_id list; "*" = any
    +        #   - "manager1234"
             extra:
               client_id: "your-app-key"      # or DINGTALK_CLIENT_ID env var
               client_secret: "[redacted:secret-assignment]"   # or DINGTALK_CLIENT_SECRET env var
     """
     
     import asyncio
    +import json
     import logging
     import os
     import re
     import uuid
     from datetime import datetime, timezone
    -from typing import Any, Dict, Optional
    +from typing import Any, Dict, List, Optional, Set
     
     try:
         import dingtalk_stream
    ```

    ```diff
    @@ -92,6 +101,10 @@ def __init__(self, config: PlatformConfig):
             # Map chat_id -> session_webhook for reply routing
             self._session_webhooks: Dict[str, str] = {}
     
    +        # Group-chat gating (mirrors Slack/Telegram/Discord/WhatsApp conventions)
    +        self._mention_patterns: List[re.Pattern] = self._compile_mention_patterns()
    +        self._allowed_users: Set[str] = self._load_allowed_users()
    +
         # -- Connection lifecycle -----------------------------------------------
     
         async def connect(self) -> bool:
    ```

    ```diff
    @@ -178,6 +191,118 @@ async def disconnect(self) -> None:
             self._dedup.clear()
             logger.info("[%s] Disconnected", self.name)
     
    +    # -- Group gating --------------------------------------------------------
    +
    +    def _dingtalk_require_mention(self) -> bool:
    +        """Return whether group chats should require an explicit bot trigger."""
    +        configured = self.config.extra.get("require_mention")
    +        if configured is not None:
    +            if isinstance(configured, str):
    +                return configured.lower() in ("true", "1", "yes", "on")
    +            return bool(configured)
    +        return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
    +
    +    def _dingtalk_free_response_chats(self) -> Set[str]:
    +        raw = self.config.extra.get("free_response_chats")
    +        if raw is None:
    +            raw = os.getenv("DINGTALK_FREE_RESPONSE_CHATS", "")
    +        if isinstance(raw, list):
    +            return {str(part).strip() for part in raw if str(part).strip()}
    +        return {part.strip() for part in str(raw).split(",") if part.strip()}
    +
    +    def _compile_mention_patterns(self) -> List[re.Pattern]:
    +        """Compile optional regex wake-word patterns for group triggers."""
    +        patterns = self.config.extra.get("mention_patterns") if self.config.extra else None
    +        if patterns is None:
    +            raw = os.getenv("DINGTALK_MENTION_PATTERNS", "").strip()
    +            if raw:
    +                try:
    +                    loaded = json.loads(raw)
    +                except Exception:
    +                    loaded = [part.strip() for part in raw.splitlines() if part.strip()]
    +                    if not loaded:
    +                        loaded = [part.strip() for part in raw.split(",") if part.strip()]
    +                patterns = loaded
    +
    +        if patterns is None:
    +            return []
    +        if isinstance(patterns, str):
    +            patterns = [patterns]
    +        if not isinstance(patterns, list):
    +            logger.warning(
    +                "[%s] dingtalk mention_patterns must be a list or string; got %s",
    +                self.name,
    +                type(patterns).__name__,
    +            )
    +            return []
    +
    +        compiled: List[re.Pattern] = []
    +        for pattern in patterns:
    +            if not isinstance(pattern, str) or not pattern.strip():
    +                continue
    +            try:
    +                compiled.append(re.compile(pattern, re.IGNORECASE))
    +            except re.error as exc:
    +                logger.warning("[%s] Invalid DingTalk mention pattern %r: %s", self.name, pattern, exc)
    +        if compiled:
    +            logger.info("[%s] Loaded %d DingTalk mention pattern(s)", self.name, len(compiled))
    +        return compiled
    +
    +    def _load_allowed_users(self) -> Set[str]:
    +        """Load allowed-users list from config.extra or env var.
    +
    +        IDs are matched case-insensitively against the sender's ``staff_id`` and
    +        ``sender_id``. A wildcard ``*`` disables the check.
    +        """
    +        raw = self.config.extra.get("allowed_users") if self.config.extra else None
    +        if raw is None:
    +            raw = os.getenv("DINGTALK_ALLOWED_USERS", "")
    +        if isinstance(raw, list):
    +            items = [str(part).strip() for part in raw if str(part).strip()]
    +        else:
    +            items = [part.strip() for part in str(raw).split(",") if part.strip()]
    +        return {item.lower() for item in items}
    +
    +    def _is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool:
    +        if not self._allowed_users or "*" in self._allowed_users:
    +            return True
    +        candidates = {(sender_id or "").lower(), (sender_staff_id or "").lower()}
    +        candidates.discard("")
    +        return bool(candidates & self._allowed_users)
    +
    +    def _message_mentions_bot(self, message: "ChatbotMessage") -> bool:
    +        """True if the bot was @-mentioned in a group message.
    +
    +        dingtalk-stream sets ``is_in_at_list`` on the incoming ChatbotMessage
    +        when the bot is addressed via @-mention.
    +        """
    +        return bool(getattr(message, "is_in_at_list", False))
    +
    +    def _message_matches_mention_patterns(self, text: str) -> bool:
    +        if not text or not self._mention_patterns:
    +            return False
    +        return any(pattern.search(text) for pattern in self._mention_patterns)
    +
    +    def _should_process_message(self, message: "ChatbotMessage", text: str, is_group: bool, chat_id: str) -> bool:
    +        """Apply DingTalk group trigger rules.
    +
    +        DMs remain unrestricted (subject to ``allowed_users`` which is enforced
    +        earlier). Group messages are accepted when:
    +        - the chat is explicitly allowlisted in ``free_response_chats``
    +        - ``require_mention`` is disabled
    +        - the bot is @mentioned (``is_in_at_list``)
    +        - the text matches a configured regex wake-word pattern
    +        """
    +        if not is_group:
    +            return True
    +        if chat_id and chat_id in self._dingtalk_free_response_chats():
    +            return True
    +        if not self._dingtalk_require_mention():
    +            return True
    +        if self._message_mentions_bot(message):
    +            return True
    +        return self._message_matches_mention_patterns(text)
    +
         # -- Inbound message processing -----------------------------------------
     
         async def _on_message(self, message: "ChatbotMessage") -> None:
    ```

    ```diff
    @@ -203,6 +328,22 @@ async def _on_message(self, message: "ChatbotMessage") -> None:
             chat_id = conversation_id or sender_id
             chat_type = "group" if is_group else "dm"
     
    +        # Allowed-users gate (applies to both DM and group)
    +        if not self._is_user_allowed(sender_id, sender_staff_id):
    +            logger.debug(
    +                "[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s",
    +                self.name, sender_staff_id, sender_id,
    +            )
    +            return
    +
    +        # Group mention/pattern gate
    +        if not self._should_process_message(message, text, is_group, chat_id):
    +            logger.debug(
    +                "[%s] Dropping group message that failed mention gate message_id=%s chat_id=%s",
    +                self.name, msg_id, chat_id,
    +            )
    +            return
    +
             # Store session webhook for reply routing (validate origin to prevent SSRF)
             session_webhook = getattr(message, "session_webhook", None) or ""
             if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook):
    ```
  - `scripts/release.py`

    ```diff
    @@ -84,6 +84,7 @@
         "[redacted:email]": "Farukest",
         "[redacted:email]": "rovle",
         "[redacted:email]": "kevinskysunny",
    +    "[redacted:email]": "yule975",
         "[redacted:email]": "teyrebaz33",
         "[redacted:email]": "Ruzzgar",
         "[redacted:email]": "alireza78a",
    ```
  - `tests/gateway/test_dingtalk.py`

    ```diff
    @@ -421,3 +421,157 @@ def test_empty_message(self):
             msg.rich_text = None
             assert DingTalkAdapter._extract_text(msg) == ""
     
    +
    +# ---------------------------------------------------------------------------
    +# Group gating — require_mention + allowed_users (parity with other platforms)
    +# ---------------------------------------------------------------------------
    +
    +
    +def _make_gating_adapter(monkeypatch, *, extra=None, env=None):
    +    """Build a DingTalkAdapter with only the gating fields populated.
    +
    +    Clears every DINGTALK_* gating env var before applying the caller's
    +    overrides so individual tests stay isolated.
    +    """
    +    for key in (
    +        "DINGTALK_REQUIRE_MENTION",
    +        "DINGTALK_MENTION_PATTERNS",
    +        "DINGTALK_FREE_RESPONSE_CHATS",
    +        "DINGTALK_ALLOWED_USERS",
    +    ):
    +        monkeypatch.delenv(key, raising=False)
    +    for key, value in (env or {}).items():
    +        monkeypatch.setenv(key, value)
    +    from gateway.platforms.dingtalk import DingTalkAdapter
    +    return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))
    +
    +
    +class TestAllowedUsersGate:
    +
    +    def test_empty_allowlist_allows_everyone(self, monkeypatch):
    +        adapter = _make_gating_adapter(monkeypatch)
    +        assert adapter._is_user_allowed("anyone", "any-staff") is True
    +
    +    def test_wildcard_allowlist_allows_everyone(self, monkeypatch):
    +        adapter = _make_gating_adapter(monkeypatch, extra={"allowed_users": ["*"]})
    +        assert adapter._is_user_allowed("anyone", "any-staff") is True
    +
    +    def test_matches_sender_id_case_insensitive(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"allowed_users": ["SenderABC"]}
    +        )
    +        assert adapter._is_user_allowed("senderabc", "") is True
    +
    +    def test_matches_staff_id(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"allowed_users": ["staff_1234"]}
    +        )
    +        assert adapter._is_user_allowed("", "staff_1234") is True
    +
    +    def test_rejects_unknown_user(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"allowed_users": ["staff_1234"]}
    +        )
    +        assert adapter._is_user_allowed("other-sender", "other-staff") is False
    +
    +    def test_env_var_csv_populates_allowlist(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, env={"DINGTALK_ALLOWED_USERS": "alice,bob,carol"}
    +        )
    +        assert adapter._is_user_allowed("alice", "") is True
    +        assert adapter._is_user_allowed("dave", "") is False
    +
    +
    +class TestMentionPatterns:
    +
    +    def test_empty_patterns_list(self, monkeypatch):
    +        adapter = _make_gating_adapter(monkeypatch)
    +        assert adapter._mention_patterns == []
    +        assert adapter._message_matches_mention_patterns("anything") is False
    +
    +    def test_pattern_matches_text(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"mention_patterns": ["^hermes"]}
    +        )
    +        assert adapter._message_matches_mention_patterns("hermes please help") is True
    +        assert adapter._message_matches_mention_patterns("please hermes help") is False
    +
    +    def test_pattern_is_case_insensitive(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"mention_patterns": ["^hermes"]}
    +        )
    +        assert adapter._message_matches_mention_patterns("HERMES help") is True
    +
    +    def test_invalid_regex_is_skipped_not_raised(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch,
    +            extra={"mention_patterns": ["[unclosed", "^valid"]},
    +        )
    +        # Invalid pattern dropped, valid one kept
    +        assert len(adapter._mention_patterns) == 1
    +        assert adapter._message_matches_mention_patterns("valid trigger") is True
    +
    +    def test_env_var_json_populates_patterns(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch,
    +            env={"DINGTALK_MENTION_PATTERNS": '["^bot", "^assistant"]'},
    +        )
    +        assert len(adapter._mention_patterns) == 2
    +        assert adapter._message_matches_mention_patterns("bot ping") is True
    +
    +    def test_env_var_newline_fallback_when_not_json(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch,
    +            env={"DINGTALK_MENTION_PATTERNS": "^bot\n^assistant"},
    +        )
    +        assert len(adapter._mention_patterns) == 2
    +
    +
    +class TestShouldProcessMessage:
    +
    +    def test_dm_always_accepted(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"require_mention": True}
    +        )
    +        msg = MagicMock(is_in_at_list=False)
    +        assert adapter._should_process_message(msg, "hi", is_group=False, chat_id="dm1") is True
    +
    +    def test_group_rejected_when_require_mention_and_no_trigger(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"require_mention": True}
    +        )
    +        msg = MagicMock(is_in_at_list=False)
    +        assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is False
    +
    +    def test_group_accepted_when_require_mention_disabled(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"require_mention": False}
    +        )
    +        msg = MagicMock(is_in_at_list=False)
    +        assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
    +
    +    def test_group_accepted_when_bot_is_mentioned(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch, extra={"require_mention": True}
    +        )
    +        msg = MagicMock(is_in_at_list=True)
    +        assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
    +
    +    def test_group_accepted_when_text_matches_wake_word(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch,
    +            extra={"require_mention": True, "mention_patterns": ["^hermes"]},
    +        )
    +        msg = MagicMock(is_in_at_list=False)
    +        assert adapter._should_process_message(msg, "hermes help", is_group=True, chat_id="grp1") is True
    +
    +    def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):
    +        adapter = _make_gating_adapter(
    +            monkeypatch,
    +            extra={"require_mention": True, "free_response_chats": ["grp1"]},
    +        )
    +        msg = MagicMock(is_in_at_list=False)
    +        assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
    +        # Different group still blocked
    +        assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp2") is False
    +
    ```
- **#11605: fix(dingtalk): get_connected_platforms + fire-and-forget processing + null-toolsets guard**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/config.py`

    ```diff
    @@ -307,6 +307,14 @@ def get_connected_platforms(self) -> List[Platform]:
                 # QQBot uses extra dict for app credentials
                 elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
                     connected.append(platform)
    +            # DingTalk uses client_id/client_secret from config.extra or env vars
    +            elif platform == Platform.DINGTALK and (
    +                config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
    +            ) and (
    +                config.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")
    +            ):
    +                connected.append(platform)
    +        
             return connected
         
         def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
    ```
  - `gateway/platforms/dingtalk.py`

    ```diff
    @@ -483,13 +483,39 @@ async def process(self, callback_message):
             """Called by dingtalk-stream when a message arrives.
     
             dingtalk-stream >= 0.24 passes a CallbackMessage whose `.data` contains
    -        the chatbot payload. Convert it to ChatbotMessage and await the adapter
    -        handler directly on the main event loop.
    +        the chatbot payload. Convert it to ChatbotMessage via
    +        ``ChatbotMessage.from_dict()``.
    +
    +        Message processing is dispatched as a background task so that this
    +        method returns the ACK immediately — blocking here would prevent the
    +        SDK from sending heartbeats, eventually causing a disconnect.
             """
             try:
    -            chatbot_msg = ChatbotMessage.from_dict(callback_message.data)
    -            await self._adapter._on_message(chatbot_msg)
    +            data = callback_message.data
    +            chatbot_msg = ChatbotMessage.from_dict(data)
    +
    +            # Ensure session_webhook is populated even if the SDK's
    +            # from_dict() did not map it (field name mismatch across
    +            # SDK versions).
    +            if not getattr(chatbot_msg, "session_webhook", None):
    +                webhook = (
    +                    data.get("sessionWebhook")
    +                    or data.get("session_webhook")
    +                    or ""
    +                )
    +                if webhook:
    +                    chatbot_msg.session_webhook = webhook
    +
    +            # Fire-and-forget: return ACK immediately, process in background.
    +            asyncio.create_task(self._safe_on_message(chatbot_msg))
             except Exception:
    -            logger.exception("[DingTalk] Error processing incoming message")
    +            logger.exception("[DingTalk] Error preparing incoming message")
     
             return dingtalk_stream.AckMessage.STATUS_OK, "OK"
    +
    +    async def _safe_on_message(self, chatbot_msg: "ChatbotMessage") -> None:
    +        """Wrapper that catches exceptions from _on_message."""
    +        try:
    +            await self._adapter._on_message(chatbot_msg)
    +        except Exception:
    +            logger.exception("[DingTalk] Error processing incoming message")
    ```
  - `hermes_cli/tools_config.py`

    ```diff
    @@ -512,7 +512,7 @@ def _get_platform_tools(
         """Resolve which individual toolset names are enabled for a platform."""
         from toolsets import resolve_toolset
     
    -    platform_toolsets = config.get("platform_toolsets", {})
    +    platform_toolsets = config.get("platform_toolsets") or {}
         toolset_names = platform_toolsets.get(platform)
     
         if toolset_names is None or not isinstance(toolset_names, list):
    ```
  - `scripts/release.py`

    ```diff
    @@ -70,6 +70,7 @@
         "[redacted:email]": "nosleepcassette",
         "[redacted:email]": "MestreY0d4-Uninter",
         "[redacted:email]": "davetist",
    +    "[redacted:email]": "yyq4193",
         "[redacted:email]": "Asunfly",
         # contributors (manual mapping from git names)
         "[redacted:email]": "asheriif",
    ```

    ```diff
    @@ -181,6 +182,7 @@
         "[redacted:email]": "jjovalle99",
         "[redacted:email]": "Julientalbot",
         "[redacted:email]": "kagura-agent",
    +    "[redacted:email]": "youngDoo",
         "[redacted:email]": "kamil-gwozdz",
         "[redacted:email]": "MustafaKara7",
         "[redacted:email]": "kira-ariaki",
    ```
  - `tests/gateway/test_config.py`

    ```diff
    @@ -71,6 +71,51 @@ def test_empty_platforms(self):
             config = GatewayConfig()
             assert config.get_connected_platforms() == []
     
    +    def test_dingtalk_recognised_via_extras(self):
    +        config = GatewayConfig(
    +            platforms={
    +                Platform.DINGTALK: PlatformConfig(
    +                    enabled=True,
    +                    extra={"client_id": "cid", "client_secret": "sec"},
    +                ),
    +            },
    +        )
    +        assert Platform.DINGTALK in config.get_connected_platforms()
    +
    +    def test_dingtalk_recognised_via_env_vars(self, monkeypatch):
    +        """DingTalk configured via env vars (no extras) should still be
    +        recognised as connected — covers the case where _apply_env_overrides
    +        hasn't populated extras yet."""
    +        monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_cid")
    +        monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_sec")
    +        config = GatewayConfig(
    +            platforms={
    +                Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
    +            },
    +        )
    +        assert Platform.DINGTALK in config.get_connected_platforms()
    +
    +    def test_dingtalk_missing_creds_not_connected(self, monkeypatch):
    +        monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
    +        monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
    +        config = GatewayConfig(
    +            platforms={
    +                Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
    +            },
    +        )
    +        assert Platform.DINGTALK not in config.get_connected_platforms()
    +
    +    def test_dingtalk_disabled_not_connected(self):
    +        config = GatewayConfig(
    +            platforms={
    +                Platform.DINGTALK: PlatformConfig(
    +                    enabled=False,
    +                    extra={"client_id": "cid", "client_secret": "sec"},
    +                ),
    +            },
    +        )
    +        assert Platform.DINGTALK not in config.get_connected_platforms()
    +
     
     class TestSessionResetPolicy:
         def test_roundtrip(self):
    ```
  - `tests/gateway/test_dingtalk.py`

    ```diff
    @@ -575,3 +575,109 @@ def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):
             # Different group still blocked
             assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp2") is False
     
    +
    +# ---------------------------------------------------------------------------
    +# _IncomingHandler.process — session_webhook extraction & fire-and-forget
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestIncomingHandlerProcess:
    +    """Verify that _IncomingHandler.process correctly converts callback data
    +    and dispatches message processing as a background task (fire-and-forget)
    +    so the SDK ACK is returned immediately."""
    +
    +    @pytest.mark.asyncio
    +    async def test_process_extracts_session_webhook(self):
    +        """session_webhook must be populated from callback data."""
    +        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
    +
    +        adapter = DingTalkAdapter(PlatformConfig(enabled=True))
    +        adapter._on_message = AsyncMock()
    +        handler = _IncomingHandler(adapter, asyncio.get_running_loop())
    +
    +        callback = MagicMock()
    +        callback.data = {
    +            "msgtype": "text",
    +            "text": {"content": "hello"},
    +            "senderId": "user1",
    +            "conversationId": "conv1",
    +            "sessionWebhook": "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
    +            "msgId": "msg-001",
    +        }
    +
    +        result = await handler.process(callback)
    +        # Should return ACK immediately (STATUS_OK = 200)
    +        assert result[0] == 200
    +
    +        # Let the background task run
    +        await asyncio.sleep(0.05)
    +
    +        # _on_message should have been called with a ChatbotMessage
    +        adapter._on_message.assert_called_once()
    +        chatbot_msg = adapter._on_message.call_args[0][0]
    +        assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=abc"
    +
    +    @pytest.mark.asyncio
    +    async def test_process_fallback_session_webhook_when_from_dict_misses_it(self):
    +        """If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK
    +        version mismatch), the handler should fall back to extracting it
    +        directly from the raw data dict."""
    +        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
    +
    +        adapter = DingTalkAdapter(PlatformConfig(enabled=True))
    +        adapter._on_message = AsyncMock()
    +        handler = _IncomingHandler(adapter, asyncio.get_running_loop())
    +
    +        callback = MagicMock()
    +        # Use a key that from_dict might not recognise in some SDK versions
    +        callback.data = {
    +            "msgtype": "text",
    +            "text": {"content": "hi"},
    +            "senderId": "user2",
    +            "conversationId": "conv2",
    +            "session_webhook": "https://oapi.dingtalk.com/robot/sendBySession?session=def",
    +            "msgId": "msg-002",
    +        }
    +
    +        await handler.process(callback)
    +        await asyncio.sleep(0.05)
    +
    +        adapter._on_message.assert_called_once()
    +        chatbot_msg = adapter._on_message.call_args[0][0]
    +        assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=def"
    +
    +    @pytest.mark.asyncio
    +    async def test_process_returns_ack_immediately(self):
    +        """process() must not block on _on_message — it should return
    +        the ACK tuple before the message is fully processed."""
    +        from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
    +
    +        processing_started = asyncio.Event()
    +        processing_gate = asyncio.Event()
    +
    +        async def slow_on_message(msg):
    +            processing_started.set()
    +            await processing_gate.wait()  # Block until we release
    +
    +        adapter = DingTalkAdapter(PlatformConfig(enabled=True))
    +        adapter._on_message = slow_on_message
    +        handler = _IncomingHandler(adapter, asyncio.get_running_loop())
    +
    +        callback = MagicMock()
    +        callback.data = {
    +            "msgtype": "text",
    +            "text": {"content": "test"},
    +            "senderId": "u",
    +            "conversationId": "c",
    +            "sessionWebhook": "https://oapi.dingtalk.com/x",
    +            "msgId": "m",
    +        }
    +
    +        # process() should return immediately even though _on_message blocks
    +        result = await handler.process(callback)
    +        assert result[0] == 200
    +
    +        # Clean up: release the gate so the background task finishes
    +        processing_gate.set()
    +        await asyncio.sleep(0.05)
    +
    ```
  - `tests/hermes_cli/test_tools_config.py`

    ```diff
    @@ -40,6 +40,19 @@ def test_get_platform_tools_preserves_explicit_empty_selection():
         assert enabled == set()
     
     
    +def test_get_platform_tools_handles_null_platform_toolsets():
    +    """YAML `platform_toolsets:` with no value parses as None — the old
    +    ``config.get("platform_toolsets", {})`` pattern would then crash with
    +    ``NoneType has no attribute 'get'`` on the next line. Guard against that.
    +    """
    +    config = {"platform_toolsets": None}
    +
    +    enabled = _get_platform_tools(config, "cli")
    +
    +    # Falls through to defaults instead of raising
    +    assert enabled
    +
    +
     def test_platform_toolset_summary_uses_explicit_platform_list():
         config = {}
     
    ```
- **#11509: fix(dingtalk): convert callback payloads before dispatch**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#11471`, `#11463`
- **#11518: fix(dingtalk): fire-and-forget message processing & session_webhook fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11605`, `#11564`, `#11463`

## Fix status

- Merged to main in PR #11564 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-17`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
