DingTalk integration fixes and feature updates

NousResearch/hermes-agent · cluster 11463

Data as of

Nothing open on GitHub. Fix merged in #11564. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #11564. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #11605. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #11564 feat(dingtalk): add require_mention + allowed_users gating (parity with Slack/Telegram/Discord)

    merged to main

    files: gateway/config.py, gateway/platforms/dingtalk.py, scripts/release.py, tests/gateway/test_dingtalk.py

    Show the diff
    • gateway/config.py
      @@ -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
      @@ -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
      @@ -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:
      @@ -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:
      @@ -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
      @@ -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
      @@ -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

    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

    Show the diff
    • gateway/config.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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",
      @@ -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
      @@ -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
      @@ -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
      @@ -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 = {}
       

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #11509 fix(dingtalk): convert callback payloads before dispatch · other → #11471, #11463
  • #11518 fix(dingtalk): fire-and-forget message processing & session_webhook fallback · merged via another change → #11605, #11564, #11463
Closed issues · 1
  • Closed#11463[Bug]: dingtalk channle callback error
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#11564, #11605
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
17 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.