From 49497bcddbc9dd9641ee07990fd8acbf75064e04 Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Wed, 15 Jul 2026 08:03:40 -0400 Subject: [PATCH] fix(slack): handle edited-in bot mentions --- plugins/platforms/slack/adapter.py | 48 +++++++++++++++++-- tests/gateway/test_slack.py | 75 ++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index df6fd8f12842..967a92556370 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -745,6 +745,11 @@ class SlackAdapter(BasePlatformAdapter): # produce a second reply. max_size bounds memory, so the long window # is safe. self._dedup = MessageDeduplicator(ttl_seconds=_slack_dedup_ttl_seconds()) + # Original Slack message timestamps that were routed into the agent. + # Used to avoid duplicate responses when an already-addressed message + # is later edited. + self._processed_message_ts: Dict[str, float] = {} + self._PROCESSED_MESSAGE_TS_MAX = 5000 # Track pending approval message_ts → resolved flag to prevent # double-clicks on approval buttons. Bounded: an approval prompt the # user never clicks would otherwise leak its entry forever. @@ -4105,8 +4110,35 @@ class SlackAdapter(BasePlatformAdapter): self, event: dict, payload: Optional[dict] = None ) -> None: """Handle an incoming Slack message event.""" + if event.get("subtype") == "message_changed": + updated_message = event.get("message") + if not isinstance(updated_message, dict): + return + + original_message_ts = str(updated_message.get("ts") or "") + if original_message_ts and original_message_ts in self._processed_message_ts: + return + edited = updated_message.get("edited") + edited_ts = "" + if isinstance(edited, dict): + edited_ts = str(edited.get("ts") or "") + outer_event_ts = str(event.get("ts") or "") + changed_event_ts = str(event.get("event_ts") or edited_ts or "") + if not changed_event_ts and outer_event_ts and outer_event_ts != original_message_ts: + changed_event_ts = outer_event_ts + if not changed_event_ts and original_message_ts: + changed_event_ts = f"{original_message_ts}:changed" + + normalized_event = dict(updated_message) + for key in ("channel", "channel_type", "team", "team_id"): + if not normalized_event.get(key) and event.get(key): + normalized_event[key] = event.get(key) + if changed_event_ts: + normalized_event["_slack_changed_event_ts"] = changed_event_ts + event = normalized_event + # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777) - event_ts = event.get("ts", "") + event_ts = event.get("_slack_changed_event_ts") or event.get("ts", "") if event_ts and self._dedup.is_duplicate(event_ts): return @@ -4151,9 +4183,10 @@ class SlackAdapter(BasePlatformAdapter): if msg_user and self._bot_user_id and msg_user == self._bot_user_id: return - # Ignore message edits and deletions + # Ignore message deletions. Edits are normalized above so an @mention + # added by edit can still wake the bot once. subtype = event.get("subtype") - if subtype in {"message_changed", "message_deleted"}: + if subtype == "message_deleted": return original_text = event.get("text", "") @@ -4998,6 +5031,15 @@ class SlackAdapter(BasePlatformAdapter): f"{msg_event.text}" ) + if ts: + self._processed_message_ts[ts] = time.time() + if len(self._processed_message_ts) > self._PROCESSED_MESSAGE_TS_MAX: + newest_items = sorted( + self._processed_message_ts.items(), + key=lambda item: item[1], + )[-self._PROCESSED_MESSAGE_TS_MAX :] + self._processed_message_ts = dict(newest_items) + await self.handle_message(msg_event) # ----- Approval button support (Block Kit) ----- diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index ff2856c50f6b..e1be8a11882f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -2969,19 +2969,86 @@ class TestMessageRouting: adapter.handle_message.assert_not_called() @pytest.mark.asyncio - async def test_message_edits_ignored(self, adapter): - """Message edits should be ignored.""" + async def test_message_deletions_ignored(self, adapter): + """Message deletions should be ignored.""" event = { - "text": "edited message", "user": "U_USER", "channel": "C123", "channel_type": "im", "ts": "1234567890.000001", - "subtype": "message_changed", + "subtype": "message_deleted", } await adapter._handle_slack_message(event) adapter.handle_message.assert_not_called() + @pytest.mark.asyncio + async def test_message_edit_with_new_mention_processed(self, adapter): + """Editing @bot into a previously ignored MPIM message should route once.""" + original_event = { + "text": "whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(original_event) + adapter.handle_message.assert_not_called() + + edited_event = { + "subtype": "message_changed", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + "message": { + "text": "<@U_BOT> whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "ts": "1234567890.000001", + "edited": {"user": "U_USER", "ts": "1234567899.000001"}, + }, + } + await adapter._handle_slack_message(edited_event) + + adapter.handle_message.assert_called_once() + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "whats the rapchat summary for last 12 hours" + assert msg_event.message_id == "1234567890.000001" + + @pytest.mark.asyncio + async def test_message_edit_after_processed_mention_ignored(self, adapter): + """Editing an already-routed @mention should not produce a duplicate reply.""" + original_event = { + "text": "<@U_BOT> first version", + "user": "U_USER", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(original_event) + adapter.handle_message.assert_called_once() + adapter.handle_message.reset_mock() + + edited_event = { + "subtype": "message_changed", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + "message": { + "text": "<@U_BOT> edited version", + "user": "U_USER", + "channel": "C123", + "ts": "1234567890.000001", + "edited": {"user": "U_USER", "ts": "1234567899.000001"}, + }, + } + await adapter._handle_slack_message(edited_event) + + adapter.handle_message.assert_not_called() + # --------------------------------------------------------------------------- # TestSendTyping — assistant.threads.setStatus