Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,15 @@ async def _process_message(self, message: Dict[str, Any]) -> None:
if message_id and self._dedup.is_duplicate(message_id):
return

# Secondary content-fingerprint dedup for text messages
item_list = message.get("item_list") or []
text = _extract_text(item_list)
if text:
content_key = f"content:{sender_id}:{hashlib.md5(text.encode()).hexdigest()}"
if self._dedup.is_duplicate(content_key):
logger.debug("[%s] Content-dedup: skipping duplicate message from %s", self.name, sender_id)
return

chat_type, effective_chat_id = _guess_chat_type(message, self._account_id)
if chat_type == "group":
if self._group_policy == "disabled":
Expand All @@ -1322,8 +1331,6 @@ async def _process_message(self, message: Dict[str, Any]) -> None:
self._token_store.set(self._account_id, sender_id, context_token)
asyncio.create_task(self._maybe_fetch_typing_ticket(sender_id, context_token or None))

item_list = message.get("item_list") or []
text = _extract_text(item_list)
media_paths: List[str] = []
media_types: List[str] = []

Expand Down
42 changes: 41 additions & 1 deletion tests/gateway/test_weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch

from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
Expand Down Expand Up @@ -758,3 +758,43 @@ def test_send_file_sets_voice_metadata_for_silk_payload(
assert voice_item["encode_type"] == 6
assert voice_item["sample_rate"] == 24000
assert voice_item["bits_per_sample"] == 16


class TestWeixinContentDedup:
"""Regression tests for Issue #16182 β€” upstream API sends duplicate content
with different message_ids, bypassing message_id deduplication.
"""

def test_duplicate_content_with_different_message_ids_is_dropped(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()

base_msg = {
"from_user_id": "wxid_user1",
"item_list": [{"type": 1, "text_item": {"text": "hello world"}}],
}

asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-1"}))
asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-2"}))

assert adapter.handle_message.await_count == 1
event = adapter.handle_message.await_args[0][0]
assert event.text == "hello world"

def test_content_dedup_not_called_for_messages_without_text(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
adapter._dedup.is_duplicate = Mock(return_value=False)

empty_msg = {
"from_user_id": "wxid_user1",
"message_id": "msg-1",
"item_list": [],
}
asyncio.run(adapter._process_message(empty_msg))

assert adapter.handle_message.await_count == 0
# is_duplicate should only be called for message_id, never for content
assert all("content:" not in str(call) for call in adapter._dedup.is_duplicate.call_args_list)