diff --git a/nanobot/channels/dingtalk/runtime.py b/nanobot/channels/dingtalk/runtime.py index 7e990fe0..b1ae38c2 100644 --- a/nanobot/channels/dingtalk/runtime.py +++ b/nanobot/channels/dingtalk/runtime.py @@ -175,6 +175,7 @@ class DingTalkConfig(Base): allow_remote_media_redirects: bool = False remote_media_redirect_allowed_hosts: list[str] = Field(default_factory=list) group_user_isolation: bool = False # If True, each user in group chat gets their own session + disable_private_chat: bool = False # If True, reject 1:1 DMs with a notice; group chats only class DingTalkChannel(BaseChannel): @@ -750,6 +751,21 @@ class DingTalkChannel(BaseChannel): session_key = None if is_group and self.config.group_user_isolation: session_key = f"{self.name}:group:{conversation_id}:{sender_id}" + + if not is_group and self.config.disable_private_chat: + # Private chat is disabled: reply with a notice and drop the + # message before any permission/pairing logic runs, so even + # allowlisted users are redirected to group chat. + self.logger.info("private chat disabled; rejecting DM from {}", sender_name) + await self.send( + OutboundMessage( + channel=self.name, + chat_id=str(chat_id), + content="该机器人未开启私聊,请在群聊中与我对话。", + ) + ) + return + await self._handle_message( sender_id=sender_id, chat_id=chat_id, diff --git a/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py index 884cf168..9004760a 100644 --- a/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py +++ b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py @@ -153,6 +153,85 @@ async def test_group_user_isolation_true_separates_sessions() -> None: assert msg1.chat_id == msg2.chat_id == "group:conv123" +@pytest.mark.asyncio +async def test_dm_rejected_when_private_chat_disabled(monkeypatch) -> None: + """With disable_private_chat=True, a 1:1 DM is rejected: nothing reaches the + bus (no session is created) and the bot replies with a notice directing the + user to group chat. Even allowlisted senders are blocked in DMs.""" + config = DingTalkConfig( + client_id="app", + client_secret="secret", + allow_from=["*"], # even allowlisted senders are blocked in DMs + disable_private_chat=True, + ) + bus = MessageBus() + channel = DingTalkChannel(config, bus) + + async def fake_get_token(): + return "test-token" + + monkeypatch.setattr(channel, "_get_access_token", fake_get_token) + channel._http = _FakeHttp() + + await channel._on_message( + "hello", + sender_id="user1", + sender_name="Alice", + conversation_type="1", + ) + + # No inbound message was published -> no session created + assert bus.inbound.empty() + + # A notice was sent back to the DM user via the private-chat API + assert len(channel._http.calls) == 1 + call = channel._http.calls[0] + assert call["url"] == "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + assert call["json"]["msgKey"] == "sampleMarkdown" + assert call["json"]["userIds"] == ["user1"] + assert "该机器人未开启私聊,请在群聊中与我对话。" in call["json"]["msgParam"] + + +@pytest.mark.asyncio +async def test_dm_allowed_when_private_chat_not_disabled() -> None: + """By default (disable_private_chat=False), a 1:1 DM still reaches the bus.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + bus = MessageBus() + channel = DingTalkChannel(config, bus) + + await channel._on_message( + "hello", + sender_id="user1", + sender_name="Alice", + conversation_type="1", + ) + + msg = await bus.consume_inbound() + assert msg.chat_id == "user1" + assert msg.metadata["conversation_type"] == "1" + + +@pytest.mark.asyncio +async def test_group_message_allowed_when_private_chat_disabled() -> None: + """Disabling private chat must not affect group messages.""" + config = DingTalkConfig( + client_id="app", client_secret="secret", allow_from=["*"], disable_private_chat=True + ) + bus = MessageBus() + channel = DingTalkChannel(config, bus) + + await channel._on_message( + "hello", + sender_id="user1", + sender_name="Alice", + conversation_type="2", + conversation_id="conv123", + ) + + msg = await bus.consume_inbound() + assert msg.chat_id == "group:conv123" + + @pytest.mark.asyncio async def test_group_send_uses_group_messages_api() -> None: config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])