Nothing open on GitHub. Fix merged in #11564. Check it covers your case.
The short answer
- 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
Open PRs0
Fixes on record
-
#11564 feat(dingtalk): add require_mention + allowed_users gating (parity with Slack/Telegram/Discord)
files:
gateway/config.py,gateway/platforms/dingtalk.py,scripts/release.py,tests/gateway/test_dingtalk.pyShow 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
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.pyShow 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
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.00means 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