From 1e8ad2f91df5a30c3a86d2d307931f754e28e386 Mon Sep 17 00:00:00 2001 From: Mibayy Date: Fri, 3 Apr 2026 23:00:49 +0200 Subject: [PATCH] fix(gateway): add message deduplication to Discord and Slack adapters (#4777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord RESUME replays events after reconnects (~7/day observed), and Slack Socket Mode can redeliver events if the ack was lost. Neither adapter tracked which messages were already processed, causing duplicate bot responses. Add _seen_messages dedup cache (message ID → timestamp) with 5-min TTL and 2000-entry cap to both adapters, matching the pattern already used by Mattermost, Matrix, WeCom, Feishu, DingTalk, and Email. The check goes at the very top of the message handler, before any other logic, so replayed events are silently dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/discord.py | 18 ++++++++++++++++++ gateway/platforms/slack.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 91e6710d26b4..21fa69b6eb72 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -449,6 +449,11 @@ def __init__(self, config: PlatformConfig): self._bot_task: Optional[asyncio.Task] = None # Cap to prevent unbounded growth (Discord threads get archived). self._MAX_TRACKED_THREADS = 500 + # Dedup cache: message_id → timestamp. Prevents duplicate bot + # responses when Discord RESUME replays events after reconnects. + self._seen_messages: Dict[str, float] = {} + self._SEEN_TTL = 300 # 5 minutes + self._SEEN_MAX = 2000 # prune threshold async def connect(self) -> bool: """Connect to Discord and start receiving events.""" @@ -539,6 +544,19 @@ async def on_ready(): @self._client.event async def on_message(message: DiscordMessage): + # Dedup: Discord RESUME replays events after reconnects (#4777) + msg_id = str(message.id) + now = time.time() + if msg_id in adapter_self._seen_messages: + return + adapter_self._seen_messages[msg_id] = now + if len(adapter_self._seen_messages) > adapter_self._SEEN_MAX: + cutoff = now - adapter_self._SEEN_TTL + adapter_self._seen_messages = { + k: v for k, v in adapter_self._seen_messages.items() + if v > cutoff + } + # Always ignore our own messages if message.author == self._client.user: return diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index be11803504d1..2e7bbee739ba 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -13,6 +13,7 @@ import logging import os import re +import time from typing import Dict, Optional, Any try: @@ -78,6 +79,11 @@ def __init__(self, config: PlatformConfig): self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id self._channel_team: Dict[str, str] = {} # channel_id → team_id + # Dedup cache: event_ts → timestamp. Prevents duplicate bot + # responses when Socket Mode reconnects redeliver events. + self._seen_messages: Dict[str, float] = {} + self._SEEN_TTL = 300 # 5 minutes + self._SEEN_MAX = 2000 # prune threshold async def connect(self) -> bool: """Connect to Slack via Socket Mode.""" @@ -710,6 +716,20 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: async def _handle_slack_message(self, event: dict) -> None: """Handle an incoming Slack message event.""" + # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777) + event_ts = event.get("ts", "") + if event_ts: + now = time.time() + if event_ts in self._seen_messages: + return + self._seen_messages[event_ts] = now + if len(self._seen_messages) > self._SEEN_MAX: + cutoff = now - self._SEEN_TTL + self._seen_messages = { + k: v for k, v in self._seen_messages.items() + if v > cutoff + } + # Ignore bot messages (including our own) if event.get("bot_id") or event.get("subtype") == "bot_message": return