fix(slack): handle edited-in bot mentions

This commit is contained in:
Seth Miller 2026-07-15 08:03:40 -04:00 committed by Teknium
parent c2a49db525
commit b32d55494f
2 changed files with 116 additions and 7 deletions

View file

@ -691,6 +691,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.
self._approval_resolved: Dict[str, bool] = {}
@ -3837,8 +3842,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
@ -3883,9 +3915,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", "")
@ -4687,6 +4720,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) -----

View file

@ -2483,19 +2483,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