Skip to content

fix(gateway): Discord and Slack adapters missing message deduplication #4777

@zackmohorn

Description

@zackmohorn

Bug

Discord and Slack adapters process replayed events after reconnects, causing duplicate bot responses.

Root Cause

When Discord's gateway drops and RESUMEs, it replays events from the disconnect window. Similarly, Slack Socket Mode reconnects can redeliver events if the ack was lost before disconnect. Neither adapter tracks which messages have already been processed.

Our gateway logs show ~7 RESUME events per day on Discord alone:

2026-04-02 03:33:07 INFO discord.gateway: Shard ID None has successfully RESUMED session...
2026-04-02 03:35:11 INFO discord.gateway: Shard ID None has successfully RESUMED session...
2026-04-02 05:49:57 INFO discord.gateway: Shard ID None has successfully RESUMED session...
(... 4 more on the same day)

Existing Pattern

Six other platform adapters already implement this correctly:

Adapter Dedup Field File
Mattermost _seen_posts gateway/platforms/mattermost.py:99-102
Matrix _processed_events gateway/platforms/matrix.py:117-136
WeCom _seen_messages gateway/platforms/wecom.py:95-96
Feishu _seen_messages gateway/platforms/feishu.py:952-955
DingTalk _seen_messages gateway/platforms/dingtalk.py:89-90
Email _seen_uids gateway/platforms/email.py:238-240

Discord and Slack are the two most popular platforms and both lack this guard.

Suggested Fix

Add a _seen_messages dict (message ID → timestamp) with 5-min TTL and 2000-entry cap to both adapters. The check goes at the very top of the message handler, before any other logic.

Discord (gateway/platforms/discord.py) — in __init__:

self._seen_messages: Dict[str, float] = {}
self._SEEN_TTL = 300   # 5 minutes
self._SEEN_MAX = 2000  # prune threshold

At the top of on_message, before the message.author == self._client.user check:

# Dedup: Discord RESUME replays events after reconnects.
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
    }

Slack (gateway/platforms/slack.py) — same pattern in __init__, and at the top of _handle_slack_message using event.get("ts") as the dedup key.

Why not _active_sessions / _running_agents?

These existing guards in base.py and run.py only prevent concurrent processing of the same session. Once the first response completes and the agent is cleaned up, a replayed event passes right through and triggers a second response.

Environment

  • Hermes Agent @ cc54818d (latest main as of 2026-04-03)
  • Discord.py with frequent RESUME cycles (~7/day)
  • Slack Socket Mode with intermittent reconnects

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions