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
214 changes: 212 additions & 2 deletions plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ class _ThreadContextCache:
parent_text: str = "" # Raw text of the thread parent (for reply_to_text injection)


@dataclass
class _MentionOnlyThreadState:
"""In-memory state for Slack threads muted until an explicit @mention."""

enabled_at: float = field(default_factory=time.monotonic)
updated_at: float = field(default_factory=time.monotonic)


def check_slack_requirements() -> bool:
"""Check if Slack dependencies are available.

Expand Down Expand Up @@ -460,6 +468,11 @@ def __init__(self, config: PlatformConfig):
# respond to ALL subsequent messages in that thread automatically.
self._mentioned_threads: set = set()
self._MENTIONED_THREADS_MAX = 5000
# Track threads where users explicitly asked the bot to stay silent
# until @mentioned. Keyed by (channel_id, thread_ts) and pruned by TTL
# so long-running gateway processes don't accumulate stale thread flags.
self._mention_only_threads: Dict[Tuple[str, str], _MentionOnlyThreadState] = {}
self._MENTION_ONLY_THREADS_MAX = 5000
# Assistant thread metadata keyed by (channel_id, thread_ts). Slack's
# AI Assistant lifecycle events can arrive before/alongside message
# events, and they carry the user/thread identity needed for stable
Expand Down Expand Up @@ -2816,14 +2829,63 @@ async def _handle_slack_message(self, event: dict) -> None:
# 4. There's an existing session for this thread (survives restarts)
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
routing_text = original_text or ""
is_slack_mentioned = bool(bot_uid and f"<@{bot_uid}>" in routing_text)
is_mentioned = bool(
(bot_uid and f"<@{bot_uid}>" in routing_text)
is_slack_mentioned
or self._slack_message_matches_mention_patterns(routing_text)
)
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
# Thread-level mention-only flags are keyed by the real Slack thread
# root. For a top-level root message, use its own ts so follow-up
# replies in that Slack thread inherit the flag.
mention_only_thread_ts = (
event_thread_ts if is_thread_reply else (event_thread_ts or ts)
)

if not is_dm and bot_uid:
mention_only_control = self._slack_thread_mention_only_control(routing_text)
if mention_only_control == "enable":
self._set_slack_thread_mention_only(
channel_id,
mention_only_thread_ts,
enabled=True,
)
logger.debug(
"[Slack] Enabled mention-only mode for thread %s/%s",
channel_id,
mention_only_thread_ts,
)
return
if mention_only_control == "disable":
self._set_slack_thread_mention_only(
channel_id,
mention_only_thread_ts,
enabled=False,
)
logger.debug(
"[Slack] Disabled mention-only mode for thread %s/%s",
channel_id,
mention_only_thread_ts,
)
if not is_mentioned:
return

if (
self._slack_thread_is_mention_only(
channel_id,
mention_only_thread_ts,
refresh=is_slack_mentioned,
)
and not is_slack_mentioned
):
logger.debug(
"[Slack] Ignoring unmentioned message in mention-only thread %s/%s",
channel_id,
mention_only_thread_ts,
)
return

# Check allowed channels — if set, only respond in these channels (whitelist)
allowed_channels = self._slack_allowed_channels()
if allowed_channels and channel_id not in allowed_channels:
Expand Down Expand Up @@ -2865,7 +2927,11 @@ async def _handle_slack_message(self, event: dict) -> None:
# Skipped in strict mode: strict_mention=true bots must be
# re-mentioned every turn, so remembering the thread would
# defeat the feature (and re-enable agent-to-agent ack loops).
if event_thread_ts and not self._slack_strict_mention():
if (
event_thread_ts
and not self._slack_strict_mention()
and not self._slack_thread_is_mention_only(channel_id, event_thread_ts)
):
self._mentioned_threads.add(event_thread_ts)
if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX:
to_remove = list(self._mentioned_threads)[
Expand Down Expand Up @@ -4143,6 +4209,150 @@ async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes

# ── Channel mention gating ─────────────────────────────────────────────

def _slack_mention_only_thread_ttl_seconds(self) -> float:
"""Return TTL for per-thread mention-only flags.

Defaults to the gateway Slack/group idle reset window when available,
otherwise 24h. Operators can override with
``platforms.slack.extra.mention_only_thread_ttl_minutes`` or
``SLACK_MENTION_ONLY_THREAD_TTL_MINUTES``.
"""
raw = self.config.extra.get("mention_only_thread_ttl_minutes")
if raw is None:
raw = os.getenv("SLACK_MENTION_ONLY_THREAD_TTL_MINUTES", "")
if raw not in (None, ""):
try:
minutes = float(raw)
if minutes > 0:
return minutes * 60.0
except (TypeError, ValueError):
logger.debug("[Slack] Invalid mention-only thread TTL: %r", raw)

session_store = getattr(self, "_session_store", None)
gateway_config = getattr(session_store, "config", None)
if gateway_config is not None:
try:
policy = gateway_config.get_reset_policy(
platform=Platform.SLACK,
session_type="group",
)
if policy.mode in {"idle", "both"} and policy.idle_minutes > 0:
return float(policy.idle_minutes) * 60.0
except Exception:
logger.debug(
"[Slack] Could not derive mention-only TTL from reset policy",
exc_info=True,
)
return 24.0 * 60.0 * 60.0

def _cleanup_slack_mention_only_threads(
self,
now: Optional[float] = None,
) -> None:
"""Prune stale/overflow per-thread mention-only flags."""
states = getattr(self, "_mention_only_threads", None)
if not states:
return
now = time.monotonic() if now is None else now
ttl = self._slack_mention_only_thread_ttl_seconds()
stale = [key for key, state in states.items() if now - state.updated_at > ttl]
for key in stale:
states.pop(key, None)

max_entries = getattr(self, "_MENTION_ONLY_THREADS_MAX", 5000)
if len(states) > max_entries:
excess = len(states) - max_entries // 2
oldest = sorted(states.items(), key=lambda item: item[1].updated_at)
for key, _state in oldest[:excess]:
states.pop(key, None)

def _slack_thread_key(
self,
channel_id: str,
thread_ts: Optional[str],
) -> Optional[Tuple[str, str]]:
"""Return the stable key for a Slack channel thread flag."""
if not channel_id or not thread_ts:
return None
return (str(channel_id), str(thread_ts))

def _set_slack_thread_mention_only(
self,
channel_id: str,
thread_ts: Optional[str],
*,
enabled: bool,
) -> None:
"""Enable/disable mention-only mode for one Slack thread."""
if not hasattr(self, "_mention_only_threads"):
self._mention_only_threads = {}
now = time.monotonic()
self._cleanup_slack_mention_only_threads(now=now)
key = self._slack_thread_key(channel_id, thread_ts)
if not key:
return
if enabled:
existing = self._mention_only_threads.get(key)
self._mention_only_threads[key] = _MentionOnlyThreadState(
enabled_at=existing.enabled_at if existing else now,
updated_at=now,
)
else:
self._mention_only_threads.pop(key, None)

def _slack_thread_is_mention_only(
self,
channel_id: str,
thread_ts: Optional[str],
*,
refresh: bool = False,
) -> bool:
"""Return True if this Slack thread is muted until explicit @mention."""
if not hasattr(self, "_mention_only_threads"):
self._mention_only_threads = {}
now = time.monotonic()
self._cleanup_slack_mention_only_threads(now=now)
key = self._slack_thread_key(channel_id, thread_ts)
if not key:
return False
state = self._mention_only_threads.get(key)
if not state:
return False
if refresh:
state.updated_at = now
return True

def _slack_thread_mention_only_control(self, text: str) -> Optional[str]:
"""Detect natural-language on/off controls for thread mention-only mode."""
normalized = str(text or "").casefold()
normalized = re.sub(r"<[@!][^>]+>", " ", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip()
if not normalized:
return None

disable_patterns = [
r"(?:침묵|뮤트|mute|mention[-_ ]?only|mention only).{0,16}(?:해제|끄|꺼|풀|off|disable|false)",
r"(?:이제|다시).{0,16}(?:나와|답|응답|말|얘기).{0,16}(?:돼|해도|괜찮)",
r"(?:멘션|태그|호출|부르).{0,16}(?:안\s*해도|없이도|없어도).{0,20}(?:답|응답|말|나와|얘기).{0,12}(?:돼|해도|괜찮|해)",
r"(?:mention|tag).{0,20}(?:not required|no longer required|without).{0,20}(?:reply|respond|answer)",
]
if any(re.search(pattern, normalized) for pattern in disable_patterns):
return "disable"

enable_patterns = [
r"(?:멘션|태그|호출|부르).{0,16}(?:할\s*때|될\s*때|일\s*때|전까지|하기\s*전|하기\s*전까지).{0,20}만.{0,20}(?:답|응답|말|얘기|나와|나오|끼어들)",
r"(?:멘션|태그|호출|부르).{0,16}(?:없(?:이|으면|을\s*때)|안\s*하면|전까지).{0,24}(?:답|응답|말|얘기|나오|나와|끼어들).{0,10}(?:마|말|않)",
r"(?:태그|멘션|호출).{0,12}전(?:까지)?.{0,16}(?:나오지|답하지|응답하지|말하지).{0,8}(?:마|말아|마라|않)",
r"(?:나오지|답하지|응답하지|말하지|얘기하지|끼어들지).{0,8}(?:마|말아|마라|않)",
r"그만.{0,8}(?:나와|답해|말해|얘기해|끼어들어)",
r"(?:only|just).{0,20}(?:reply|respond|answer).{0,20}(?:mention|tag|called)",
r"(?:do\s*not|don't|dont).{0,20}(?:reply|respond|answer|talk).{0,24}(?:unless|until).{0,20}(?:mention|tag|called)",
r"(?:stay quiet|mute|mention[-_ ]?only|mention only).{0,16}(?:on|enable|true)?",
]
if any(re.search(pattern, normalized) for pattern in enable_patterns):
return "enable"
return None

def _slack_require_mention(self) -> bool:
"""Return whether channel messages require an explicit bot mention.

Expand Down
131 changes: 131 additions & 0 deletions tests/gateway/test_slack_thread_mention_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Regression tests for Slack per-thread mention-only routing.

When a user tells a bot to stay out of a Slack thread until it is called,
the adapter should set a thread-local flag before the message reaches the
agent. While the flag is active, normal active-session / mentioned-thread /
free-response heuristics must not wake the agent; only an explicit Slack
@mention may pass.
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from gateway.config import PlatformConfig
from plugins.platforms.slack.adapter import SlackAdapter, _MentionOnlyThreadState


CHANNEL = "C_THREAD"
THREAD = "1700000000.000001"
BOT = "U_BOT"
USER = "U_USER"
TEAM = "T_TEAM"


@pytest.fixture
def adapter():
config = PlatformConfig(enabled=True, token="xoxb-test")
a = SlackAdapter(config)
a._app = MagicMock()
a._app.client = AsyncMock()
a._bot_user_id = BOT
a._running = True
a.handle_message = AsyncMock()
return a


def _thread_event(text: str, *, ts: str = "1700000000.000010") -> dict:
return {
"channel": CHANNEL,
"channel_type": "channel",
"team": TEAM,
"user": USER,
"text": text,
"ts": ts,
"thread_ts": THREAD,
}


async def _handle(adapter: SlackAdapter, event: dict) -> None:
with (
patch.object(
adapter, "_resolve_user_name", new=AsyncMock(return_value="tester")
),
patch.object(adapter, "_fetch_thread_context", new=AsyncMock(return_value="")),
patch.object(
adapter, "_fetch_thread_parent_text", new=AsyncMock(return_value=None)
),
):
await adapter._handle_slack_message(event)


@pytest.mark.asyncio
async def test_enable_phrase_sets_thread_flag_and_consumes_message(adapter):
await _handle(adapter, _thread_event(f"<@{BOT}> 이 쓰레드는 이제 태그할 때만 나와"))

assert (CHANNEL, THREAD) in adapter._mention_only_threads
adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_mention_only_thread_blocks_active_session_followups(adapter):
adapter._set_slack_thread_mention_only(CHANNEL, THREAD, enabled=True)
# These legacy wake paths must not override the explicit per-thread mute.
adapter._mentioned_threads.add(THREAD)
adapter._bot_message_ts.add(THREAD)

await _handle(adapter, _thread_event("네 그렇게 진행하면 됩니다"))

adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_mention_only_thread_blocks_free_response_followups(adapter):
adapter.config.extra["free_response_channels"] = CHANNEL
adapter._set_slack_thread_mention_only(CHANNEL, THREAD, enabled=True)

await _handle(adapter, _thread_event("free-response 채널이어도 그냥 끼어들지 마"))

adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_mention_only_thread_blocks_configured_name_patterns(adapter):
adapter.config.extra["mention_patterns"] = [r"오시야"]
adapter._set_slack_thread_mention_only(CHANNEL, THREAD, enabled=True)

await _handle(adapter, _thread_event("오시야 이건 다시 확인해줘"))

adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_mention_only_thread_allows_explicit_slack_mention(adapter):
adapter._set_slack_thread_mention_only(CHANNEL, THREAD, enabled=True)

await _handle(adapter, _thread_event(f"<@{BOT}> 이건 다시 확인해줘"))

adapter.handle_message.assert_awaited_once()
assert (CHANNEL, THREAD) in adapter._mention_only_threads
assert THREAD not in adapter._mentioned_threads


@pytest.mark.asyncio
async def test_disable_phrase_clears_thread_flag(adapter):
adapter._set_slack_thread_mention_only(CHANNEL, THREAD, enabled=True)

await _handle(adapter, _thread_event(f"<@{BOT}> 침묵 해제하고 다시 답해도 돼"))

assert (CHANNEL, THREAD) not in adapter._mention_only_threads
adapter.handle_message.assert_awaited_once()


def test_mention_only_thread_cleanup_prunes_stale_entries(adapter):
adapter.config.extra["mention_only_thread_ttl_minutes"] = 0.01
adapter._mention_only_threads[(CHANNEL, THREAD)] = _MentionOnlyThreadState(
enabled_at=0.0,
updated_at=0.0,
)

assert not adapter._slack_thread_is_mention_only(CHANNEL, THREAD)
assert (CHANNEL, THREAD) not in adapter._mention_only_threads