NousResearch/hermes-agent

Slack adapter require_mention config handling

Cluster 6218. Data as of

Nothing open on GitHub. Fix merged in #6809 on 9 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #6809. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern the Slack adapter's handling of the require_mention configuration option, including reading it from config, respecting it for channel messages, and related Slack adapter configuration improvements such as free_response_channels and enable_reactions.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #6809 feat(slack): consolidated Slack adapter improvements — 7 community PRs salvaged

    merged to main

    files: gateway/config.py, gateway/platforms/slack.py, gateway/platforms/telegram.py, hermes_state.py, tests/gateway/test_slack.py, tests/gateway/test_slack_mention.py, tests/tools/test_send_message_tool.py, tools/send_message_tool.py

    Show the diff
    • gateway/config.py
      @@ -532,6 +532,8 @@ def load_gateway_config() -> GatewayConfig:
                           bridged["reply_prefix"] = platform_cfg["reply_prefix"]
                       if "require_mention" in platform_cfg:
                           bridged["require_mention"] = platform_cfg["require_mention"]
      +                if "free_response_channels" in platform_cfg:
      +                    bridged["free_response_channels"] = platform_cfg["free_response_channels"]
                       if "mention_patterns" in platform_cfg:
                           bridged["mention_patterns"] = platform_cfg["mention_patterns"]
                       if not bridged:
      @@ -546,6 +548,19 @@ def load_gateway_config() -> GatewayConfig:
                           plat_data["extra"] = extra
                       extra.update(bridged)
       
      +            # Slack settings → env vars (env vars take precedence)
      +            slack_cfg = yaml_cfg.get("slack", {})
      +            if isinstance(slack_cfg, dict):
      +                if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
      +                    os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
      +                if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
      +                    os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
      +                frc = slack_cfg.get("free_response_channels")
      +                if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
      +                    if isinstance(frc, list):
      +                        frc = ",".join(str(v) for v in frc)
      +                    os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
      +
                   # Discord settings → env vars (env vars take precedence)
                   discord_cfg = yaml_cfg.get("discord", {})
                   if isinstance(discord_cfg, dict):
    • gateway/platforms/slack.py
      @@ -14,6 +14,7 @@
       import os
       import re
       import time
      +from dataclasses import dataclass, field
       from typing import Dict, Optional, Any, Tuple
       
       try:
      @@ -45,6 +46,14 @@
       logger = logging.getLogger(__name__)
       
       
      +@dataclass
      +class _ThreadContextCache:
      +    """Cache entry for fetched thread context."""
      +    content: str
      +    fetched_at: float = field(default_factory=time.monotonic)
      +    message_count: int = 0
      +
      +
       def check_slack_requirements() -> bool:
           """Check if Slack dependencies are available."""
           return SLACK_AVAILABLE
      @@ -101,6 +110,9 @@ def __init__(self, config: PlatformConfig):
               # session + memory scoping.
               self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}
               self._ASSISTANT_THREADS_MAX = 5000
      +        # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
      +        self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
      +        self._THREAD_CACHE_TTL = 60.0
       
           async def connect(self) -> bool:
               """Connect to Slack via Socket Mode."""
      @@ -281,6 +293,7 @@ async def send(
                       kwargs = {
                           "channel": chat_id,
                           "text": chunk,
      +                    "mrkdwn": True,
                       }
                       if thread_ts:
                           kwargs["thread_ts"] = thread_ts
      @@ -323,9 +336,7 @@ async def edit_message(
               if not self._app:
                   return SendResult(success=False, error="Not connected")
               try:
      -            # Convert standard markdown → Slack mrkdwn
                   formatted = self.format_message(content)
      -
                   await self._get_client(chat_id).chat_update(
                       channel=chat_id,
                       ts=message_id,
      @@ -457,13 +468,36 @@ def _ph(value: str) -> str:
               text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
       
               # 3) Convert markdown links [text](url) → <url|text>
      +        def _convert_markdown_link(m):
      +            label = m.group(1)
      +            url = m.group(2).strip()
      +            if url.startswith('<') and url.endswith('>'):
      +                url = url[1:-1].strip()
      +            return _ph(f'<{url}|{label}>')
      +
      +        text = re.sub(
      +            r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
      +            _convert_markdown_link,
      +            text,
      +        )
      +
      +        # 4) Protect existing Slack entities/manual links so escaping and later
      +        #    formatting passes don't break them.
               text = re.sub(
      -            r'\[([^\]]+)\]\(([^)]+)\)',
      -            lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
      +            r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)',
      +            lambda m: _ph(m.group(1)),
                   text,
               )
       
      -        # 4) Convert headers (## Title) → *Title* (bold)
      +        # 5) Protect blockquote markers before escaping
      +        text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE)
      +
      +        # 6) Escape Slack control characters in remaining plain text.
      +        # Unescape first so already-escaped input doesn't get double-escaped.
      +        text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
      +        text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
      +
      +        # 7) Convert headers (## Title) → *Title* (bold)
               def _convert_header(m):
                   inner = m.group(1).strip()
                   # Strip redundant bold markers inside a header
      @@ -474,34 +508,39 @@ def _convert_header(m):
                   r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
               )
       
      -        # 5) Convert bold: **text** → *text* (Slack bold)
      +        # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic)
      +        text = re.sub(
      +            r'\*\*\*(.+?)\*\*\*',
      +            lambda m: _ph(f'*_{m.group(1)}_*'),
      +            text,
      +        )
      +
      +        # 9) Convert bold: **text** → *text* (Slack bold)
               text = re.sub(
                   r'\*\*(.+?)\*\*',
                   lambda m: _ph(f'*{m.group(1)}*'),
                   text,
               )
       
      -        # 6) Convert italic: _text_ stays as _text_ (already Slack italic)
      -        #    Single *text* → _text_ (Slack italic)
      +        # 10) Convert italic: _text_ stays as _text_ (already Slack italic)
      +        #     Single *text* → _text_ (Slack italic)
               text = re.sub(
                   r'(?<!\*)\*([^*\n]+)\*(?!\*)',
                   lambda m: _ph(f'_{m.group(1)}_'),
                   text,
               )
       
      -        # 7) Convert strikethrough: ~~text~~ → ~text~
      +        # 11) Convert strikethrough: ~~text~~ → ~text~
               text = re.sub(
                   r'~~(.+?)~~',
                   lambda m: _ph(f'~{m.group(1)}~'),
                   text,
               )
       
      -        # 8) Convert blockquotes: > text → > text (same syntax, just ensure
      -        #    no extra escaping happens to the > character)
      -        # Slack uses the same > prefix, so this is a no-op for content.
      +        # 12) Blockquotes: > prefix is already protected by step 5 above.
       
      -        # 9) Restore placeholders in reverse order
      -        for key in reversed(list(placeholders.keys())):
      +        # 13) Restore placeholders in reverse order
      +        for key in reversed(placeholders):
                   text = text.replace(key, placeholders[key])
       
               return text
      @@ -914,9 +953,26 @@ async def _handle_slack_message(self, event: dict) -> None:
                           if v > cutoff
                       }
       
      -        # Ignore bot messages (including our own)
      +        # Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots):
      +        #   "none"     — ignore all bot messages (default, backward-compatible)
      +        #   "mentions" — accept bot messages only when they @mention us
      +        #   "all"      — accept all bot messages (except our own)
               if event.get("bot_id") or event.get("subtype") == "bot_message":
      -            return
      +            allow_bots = self.config.extra.get("allow_bots", "")
      +            if not allow_bots:
      +                allow_bots = os.getenv("SLACK_ALLOW_BOTS", "none")
      +            allow_bots = str(allow_bots).lower().strip()
      +            if allow_bots == "none":
      +                return
      +            elif allow_bots == "mentions":
      +                text_check = event.get("text", "")
      +                if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check:
      +                    return
      +            # "all" falls through to process the message
      +            # Always ignore our own messages to prevent echo loops
      +            msg_user = event.get("user", "")
      +            if msg_user and self._bot_user_id and msg_user == self._bot_user_id:
      +                return
       
               # Ignore message edits and deletions
               subtype = event.get("subtype")
      @@ -948,7 +1004,7 @@ async def _handle_slack_message(self, event: dict) -> None:
               channel_type = event.get("channel_type", "")
               if not channel_type and channel_id.startswith("D"):
                   channel_type = "im"
      -        is_dm = channel_type == "im"
      +        is_dm = channel_type in ("im", "mpim")  # Both 1:1 and group DMs
       
               # Build thread_ts for session keying.
               # In channels: fall back to ts so each top-level @mention starts a
      @@ -961,6 +1017,8 @@ async def _handle_slack_message(self, event: dict) -> None:
                   thread_ts = event.get("thread_ts") or ts  # ts fallback for channels
       
               # In channels, respond if:
      +        #   0. Channel is in free_response_channels, OR require_mention is
      +        #      disabled — always process regardless of mention.
               #   1. The bot is @mentioned in this message, OR
               #   2. The message is a reply in a thread the bot started/participated in, OR
               #   3. The message is in a thread where the bot was previously @mentioned, OR
      @@ -970,24 +1028,29 @@ async def _handle_slack_message(self, event: dict) -> None:
               event_thread_ts = event.get("thread_ts")
               is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
       
      -        if not is_dm and bot_uid and not is_mentioned:
      -            reply_to_bot_thread = (
      -                is_thread_reply and event_thread_ts in self._bot_message_ts
      -            )
      -            in_mentioned_thread = (
      -                event_thread_ts is not None
      -                and event_thread_ts in self._mentioned_threads
      -            )
      -            has_session = (
      -                is_thread_reply
      -                and self._has_active_session_for_thread(
      -                    channel_id=channel_id,
      -                    thread_ts=event_thread_ts,
      -                    user_id=user_id,
      +        if not is_dm and bot_uid:
      +            if channel_id in self._slack_free_response_channels():
      +                pass  # Free-response channel — always process
      +            elif not self._slack_require_mention():
      +                pass  # Mention requirement disabled globally for Slack
      +            elif not is_mentioned:
      +                reply_to_bot_thread = (
      +                    is_thread_reply and event_thread_ts in self._bot_message_ts
                       )
      -            )
      -            if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
      -                return
      +                in_mentioned_thread = (
      +                    event_thread_ts is not None
      +                    and event_thread_ts in self._mentioned_threads
      +                )
      +                has_session = (
      +                    is_thread_reply
      +                    and self._has_active_session_for_thread(
      +                        channel_id=channel_id,
      +                        thread_ts=event_thread_ts,
      +                        user_id=user_id,
      +                    )
      +                )
      +                if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
      +                    return
       
               if is_mentioned:
                   # Strip the bot mention from the text
      @@ -1128,14 +1191,19 @@ async def _handle_slack_message(self, event: dict) -> None:
                   reply_to_message_id=thread_ts if thread_ts != ts else None,
               )
       
      -        # Add 👀 reaction to acknowledge receipt
      -        await self._add_reaction(channel_id, ts, "eyes")
      +        # Only react when bot is directly addressed (DM or @mention).
      +        # In listen-all channels (require_mention=false), reacting to every
      +        # casual message would be noisy.
      +        _should_react = is_dm or is_mentioned
      +
      +        if _should_react:
      +            await self._add_reaction(channel_id, ts, "eyes")
       
               await self.handle_message(msg_event)
       
      -        # Replace 👀 with ✅ when done
      -        await self._remove_reaction(channel_id, ts, "eyes")
      -        await self._add_reaction(channel_id, ts, "white_check_mark")
      +        if _should_react:
      +            await self._remove_reaction(channel_id, ts, "eyes")
      +            await self._add_reaction(channel_id, ts, "white_check_mark")
       
           # ----- Approval button support (Block Kit) -----
       
      @@ -1229,6 +1297,20 @@ async def _handle_approval_action(self, ack, body, action) -> None:
               msg_ts = message.get("ts", "")
               channel_id = body.get("channel", {}).get("id", "")
               user_name = body.get("user", {}).get("name", "unknown")
      +        user_id = body.get("user", {}).get("id", "")
      +
      +        # Only authorized users may click approval buttons.  Button clicks
      +        # bypass the normal message auth flow in gateway/run.py, so we must
      +        # check here as well.
      +        allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
      +        if allowed_csv:
      +            allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
      +            if "*" not in allowed_ids and user_id not in allowed_ids:
      +                logger.warning(
      +                    "[Slack] Unauthorized approval click by %s (%s) — ignoring",
      +                    user_name, user_id,
      +                )
      +                return
       
               # Map action_id to approval choice
               choice_map = {
      @@ -1239,10 +1321,9 @@ async def _handle_approval_action(self, ack, body, action) -> None:
               }
               choice = choice_map.get(action_id, "deny")
       
      -        # Prevent double-clicks
      -        if self._approval_resolved.get(msg_ts, False):
      +        # Prevent double-clicks — atomic pop; first caller gets False, others get True (default)
      +        if self._approval_resolved.pop(msg_ts, True):
                   return
      -        self._approval_resolved[msg_ts] = True
       
               # Update the message to show the decision and remove buttons
               label_map = {
      @@ -1297,8 +1378,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:
               except Exception as exc:
                   logger.error("Failed to resolve gateway approval from Slack button: %s", exc)
       
      -        # Clean up stale approval state
      -        self._approval_resolved.pop(msg_ts, None)
      +        # (approval state already consumed by atomic pop above)
       
           # ----- Thread context fetching -----
       
      @@ -1309,57 +1389,104 @@ async def _fetch_thread_context(
               """Fetch recent thread messages to provide context when the bot is
               mentioned mid-thread for the first time.
       
      -        Returns a formatted string with thread history, or empty string on
      -        failure or if the thread is empty (just the parent message).
      +        This method is only called when there is NO active session for the
      +        thread (guarded at the call site by _has_active_session_for_thread).
      +        That guard ensures thread messages are prepended only on the very
      +        first turn — after that the session history already holds them, so
      +        there is no duplication across subsequent turns.
      +
      +        Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid
      +        hammering conversations.replies (Tier 3, ~50 req/min).
      +
      +        Returns a formatted string with prior thread history, or empty string
      +        on failure or if the thread has no prior messages.
               """
      +        cache_key = f"{channel_id}:{thread_ts}"
      +        now = time.monotonic()
      +        cached = self._thread_context_cache.get(cache_key)
      +        if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
      +            return cached.content
      +
               try:
                   client = self._get_client(channel_id)
      -            result = await client.conversations_replies(
      -                channel=channel_id,
      -                ts=thread_ts,
      -                limit=limit + 1,  # +1 because it includes the current message
      -                inclusive=True,
      -            )
      +
      +            # Retry with exponential backoff for Tier-3 rate limits (429).
      +            result = None
      +            for attempt in range(3):
      +                try:
      +                    result = await client.conversations_replies(
      +                        channel=channel_id,
      +                        ts=thread_ts,
      +                        limit=limit + 1,  # +1 because it includes the current message
      +                        inclusive=True,
      +                    )
      +                    break
      +                except Exception as exc:
      +                    # Check for rate-limit error from slack_sdk
      +                    err_str = str(exc).lower()
      +                    is_rate_limit = (
      +                        "ratelimited" in err_str
      +                        or "429" in err_str
      +                        or "rate_limited" in err_str
      +                    )
      +                    if is_rate_limit and attempt < 2:
      +                        retry_after = 1.0 * (2 ** attempt)  # 1s, 2s
      +                        logger.warning(
      +                            "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)",
      +                            retry_after, attempt + 1,
      +                        )
      +                        await asyncio.sleep(retry_after)
      +                        continue
      +                    raise
      +
      +            if result is None:
      +                return ""
      +
                   messages = result.get("messages", [])
                   if not messages:
                       return ""
       
      +            bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
                   context_parts = []
                   for msg in messages:
                       msg_ts = msg.get("ts", "")
      -                # Skip the current message (the one that triggered this fetch)
      +                # Exclude the current triggering message — it will be delivered
      +                # as the user message itself, so including it here would duplicate it.
                       if msg_ts == current_ts:
                           continue
      -                # Skip bot messages from ourselves
      +                # Exclude our own bot messages to avoid circular context.
                       if msg.get("bot_id") or msg.get("subtype") == "bot_message":
                           continue
       
      -                msg_user = msg.get("user", "unknown")
                       msg_text = msg.get("text", "").strip()
                       if not msg_text:
                           continue
       
                       # Strip bot mentions from context messages
      -                bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
                       if bot_uid:
                           msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
       
      -                # Mark the thread parent
      +                msg_user = msg.get("user", "unknown")
                       is_parent = msg_ts == thread_ts
                       prefix = "[thread parent] " if is_parent else ""
      -
      -                # Resolve user name (cached)
                       name = await self._resolve_user_name(msg_user, chat_id=channel_id)
                       context_parts.append(f"{prefix}{name}: {msg_text}")
       
      -            if not context_parts:
      -                return ""
      +            content = ""
      +            if context_parts:
      +                content = (
      +                    "[Thread context — prior messages in this thread (not yet in conversation history):]\n"
      +                    + "\n".join(context_parts)
      +                    + "\n[End of thread context]\n\n"
      +                )
       
      -            return (
      -                "[Thread context — previous messages in this thread:]\n"
      -                + "\n".join(context_parts)
      -                + "\n[End of thread context]\n\n"
      +            self._thread_context_cache[cache_key] = _ThreadContextCache(
      +                content=content,
      +                fetched_at=now,
      +                message_count=len(context_parts),
                   )
      +            return content
      +
               except Exception as e:
                   logger.warning("[Slack] Failed to fetch thread context: %s", e)
                   return ""
      @@ -1515,3 +1642,30 @@ async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes
                               continue
                           raise
               raise last_exc
      +
      +    # ── Channel mention gating ─────────────────────────────────────────────
      +
      +    def _slack_require_mention(self) -> bool:
      +        """Return whether channel messages require an explicit bot mention.
      +
      +        Uses explicit-false parsing (like Discord/Matrix) rather than
      +        truthy parsing, since the safe default is True (gating on).
      +        Unrecognised or empty values keep gating enabled.
      +        """
      +        configured = self.config.extra.get("require_mention")
      +        if configured is not None:
      +            if isinstance(configured, str):
      +                return configured.lower() not in ("false", "0", "no", "off")
      +            return bool(configured)
      +        return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
      +
      +    def _slack_free_response_channels(self) -> set:
      +        """Return channel IDs where no @mention is required."""
      +        raw = self.config.extra.get("free_response_channels")
      +        if raw is None:
      +            raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "")
      +        if isinstance(raw, list):
      +            return {str(part).strip() for part in raw if str(part).strip()}
      +        if isinstance(raw, str) and raw.strip():
      +            return {part.strip() for part in raw.split(",") if part.strip()}
      +        return set()
    • gateway/platforms/telegram.py
      @@ -1398,6 +1398,15 @@ async def _handle_callback_query(
                           await query.answer(text="Invalid approval data.")
                           return
       
      +                # Only authorized users may click approval buttons.
      +                caller_id = str(getattr(query.from_user, "id", ""))
      +                allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
      +                if allowed_csv:
      +                    allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
      +                    if "*" not in allowed_ids and caller_id not in allowed_ids:
      +                        await query.answer(text="⛔ You are not authorized to approve commands.")
      +                        return
      +
                       session_key = self._approval_state.pop(approval_id, None)
                       if not session_key:
                           await query.answer(text="This approval has already been resolved.")
    • hermes_state.py
      @@ -944,7 +944,8 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
                       try:
                           msg["tool_calls"] = json.loads(msg["tool_calls"])
                       except (json.JSONDecodeError, TypeError):
      -                    pass
      +                    logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
      +                    msg["tool_calls"] = []
                   result.append(msg)
               return result
       
      @@ -972,7 +973,8 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
                       try:
                           msg["tool_calls"] = json.loads(row["tool_calls"])
                       except (json.JSONDecodeError, TypeError):
      -                    pass
      +                    logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
      +                    msg["tool_calls"] = []
                   # Restore reasoning fields on assistant messages so providers
                   # that replay reasoning (OpenRouter, OpenAI, Nous) receive
                   # coherent multi-turn reasoning context.
      @@ -983,12 +985,14 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
                           try:
                               msg["reasoning_details"] = json.loads(row["reasoning_details"])
                           except (json.JSONDecodeError, TypeError):
      -                        pass
      +                        logger.warning("Failed to deserialize reasoning_details, falling back to None")
      +                        msg["reasoning_details"] = None
                       if row["codex_reasoning_items"]:
                           try:
                               msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
                           except (json.JSONDecodeError, TypeError):
      -                        pass
      +                        logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
      +                        msg["codex_reasoning_items"] = None
                   messages.append(msg)
               return messages
       
    • tests/gateway/test_slack.py
      @@ -619,6 +619,18 @@ def test_link_conversion(self, adapter):
               result = adapter.format_message("[click here](https://example.com)")
               assert result == "<https://example.com|click here>"
       
      +    def test_link_conversion_strips_markdown_angle_brackets(self, adapter):
      +        result = adapter.format_message("[click here](<https://example.com>)")
      +        assert result == "<https://example.com|click here>"
      +
      +    def test_escapes_control_characters(self, adapter):
      +        result = adapter.format_message("AT&T < 5 > 3")
      +        assert result == "AT&amp;T &lt; 5 &gt; 3"
      +
      +    def test_preserves_existing_slack_entities(self, adapter):
      +        text = "Hey <@U123>, see <https://example.com|example> and <!here>"
      +        assert adapter.format_message(text) == text
      +
           def test_strikethrough(self, adapter):
               assert adapter.format_message("~~deleted~~") == "~deleted~"
       
      @@ -643,6 +655,325 @@ def test_empty_string(self, adapter):
           def test_none_passthrough(self, adapter):
               assert adapter.format_message(None) is None
       
      +    def test_blockquote_preserved(self, adapter):
      +        """Single-line blockquote > marker is preserved."""
      +        assert adapter.format_message("> quoted text") == "> quoted text"
      +
      +    def test_multiline_blockquote(self, adapter):
      +        """Multi-line blockquote preserves > on each line."""
      +        text = "> line one\n> line two"
      +        assert adapter.format_message(text) == "> line one\n> line two"
      +
      +    def test_blockquote_with_formatting(self, adapter):
      +        """Blockquote containing bold text."""
      +        assert adapter.format_message("> **bold quote**") == "> *bold quote*"
      +
      +    def test_nested_blockquote(self, adapter):
      +        """Multiple > characters for nested quotes."""
      +        assert adapter.format_message(">> deeply quoted") == ">> deeply quoted"
      +
      +    def test_blockquote_mixed_with_plain(self, adapter):
      +        """Blockquote lines interleaved with plain text."""
      +        text = "normal\n> quoted\nnormal again"
      +        result = adapter.format_message(text)
      +        assert "> quoted" in result
      +        assert "normal" in result
      +
      +    def test_non_prefix_gt_still_escaped(self, adapter):
      +        """Greater-than in mid-line is still escaped."""
      +        assert adapter.format_message("5 > 3") == "5 &gt; 3"
      +
      +    def test_blockquote_with_code(self, adapter):
      +        """Blockquote containing inline code."""
      +        result = adapter.format_message("> use `fmt.Println`")
      +        assert result.startswith(">")
      +        assert "`fmt.Println`" in result
      +
      +    def test_bold_italic_combined(self, adapter):
      +        """Triple-star ***text*** converts to Slack bold+italic *_text_*."""
      +        assert adapter.format_message("***hello***") == "*_hello_*"
      +
      +    def test_bold_italic_with_surrounding_text(self, adapter):
      +        """Bold+italic in a sentence."""
      +        result = adapter.format_message("This is ***important*** stuff")
      +        assert "*_important_*" in result
      +
      +    def test_bold_italic_does_not_break_plain_bold(self, adapter):
      +        """**bold** still works after adding ***bold italic*** support."""
      +        assert adapter.format_message("**bold**") == "*bold*"
      +
      +    def test_bold_italic_does_not_break_plain_italic(self, adapter):
      +        """*italic* still works after adding ***bold italic*** support."""
      +        assert adapter.format_message("*italic*") == "_italic_"
      +
      +    def test_bold_italic_mixed_with_bold(self, adapter):
      +        """Both ***bold italic*** and **bold** in the same message."""
      +        result = adapter.format_message("***important*** and **bold**")
      +        assert "*_important_*" in result
      +        assert "*bold*" in result
      +
      +    def test_pre_escaped_ampersand_not_double_escaped(self, adapter):
      +        """Already-escaped &amp; must not become &amp;amp;."""
      +        assert adapter.format_message("&amp;") == "&amp;"
      +
      +    def test_pre_escaped_lt_not_double_escaped(self, adapter):
      +        """Already-escaped &lt; must not become &amp;lt;."""
      +        assert adapter.format_message("&lt;") == "&lt;"
      +
      +    def test_pre_escaped_gt_not_double_escaped(self, adapter):
      +        """Already-escaped &gt; in plain text must not become &amp;gt;."""
      +        assert adapter.format_message("5 &gt; 3") == "5 &gt; 3"
      +
      +    def test_mixed_raw_and_escaped_entities(self, adapter):
      +        """Raw & and pre-escaped &amp; coexist correctly."""
      +        result = adapter.format_message("AT&T and &amp; entity")
      +        assert result == "AT&amp;T and &amp; entity"
      +
      +    def test_link_with_parentheses_in_url(self, adapter):
      +        """Wikipedia-style URL with balanced parens is not truncated."""
      +        result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
      +        assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
      +
      +    def test_link_with_multiple_paren_pairs(self, adapter):
      +        """URL with multiple balanced paren pairs."""
      +        result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))")
      +        assert result == "<https://example.com/a_(b)_c_(d)|text>"
      +
      +    def test_link_without_parens_still_works(self, adapter):
      +        """Normal URL without parens is unaffected by regex change."""
      +        result = adapter.format_message("[click](https://example.com/path?q=1)")
      +        assert result == "<https://example.com/path?q=1|click>"
      +
      +    def test_link_with_angle_brackets_and_parens(self, adapter):
      +        """Angle-bracket URL with parens (CommonMark syntax)."""
      +        result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)")
      +        assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
      +
      +    def test_escaping_is_idempotent(self, adapter):
      +        """Formatting already-formatted text produces the same result."""
      +        original = "AT&T < 5 > 3"
      +        once = adapter.format_message(original)
      +        twice = adapter.format_message(once)
      +        assert once == twice
      +
      +    # --- Entity preservation (spec-compliance) ---
      +
      +    def test_channel_mention_preserved(self, adapter):
      +        """<!channel> special mention passes through unchanged."""
      +        assert adapter.format_message("Attention <!channel>") == "Attention <!channel>"
      +
      +    def test_everyone_mention_preserved(self, adapter):
      +        """<!everyone> special mention passes through unchanged."""
      +        assert adapter.format_message("Hey <!everyone>") == "Hey <!everyone>"
      +
      +    def test_subteam_mention_preserved(self, adapter):
      +        """<!subteam^ID> user group mention passes through unchanged."""
      +        assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>"
      +
      +    def test_date_formatting_preserved(self, adapter):
      +        """<!date^...> formatting token passes through unchanged."""
      +        text = "Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>"
      +        assert adapter.format_message(text) == text
      +
      +    def test_channel_link_preserved(self, adapter):
      +        """<#CHANNEL_ID> channel link passes through unchanged."""
      +        assert adapter.format_message("Join <#C12345>") == "Join <#C12345>"
      +
      +    # --- Additional edge cases ---
      +
      +    def test_message_only_code_block(self, adapter):
      +        """Entire message is a fenced code block — no conversion."""
      +        code = "```python\nx = 1\n```"
      +        assert adapter.format_message(code) == code
      +
      +    def test_multiline_mixed_formatting(self, adapter):
      +        """Multi-line message with headers, bold, links, code, and blockquotes."""
      +        text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`"
      +        result = adapter.format_message(text)
      +        assert result.startswith("*Title*")
      +        assert "*bold*" in result
      +        assert "<https://x.com|link>" in result
      +        assert "> quote" in result
      +        assert "`code`" in result
      +
      +    def test_markdown_unordered_list_with_asterisk(self, adapter):
      +        """Asterisk list items must not trigger italic conversion."""
      +        text = "* item one\n* item two"
      +        result = adapter.format_message(text)
      +        assert "item one" in result
      +        assert "item two" in result
      +
      +    def test_nested_bold_in_link(self, adapter):
      +        """Bold inside link label — label is stashed before bold pass."""
      +        result = adapter.format_message("[**bold**](https://example.com)")
      +        assert "https://example.com" in result
      +        assert "bold" in result
      +
      +    def test_url_with_query_string_and_ampersand(self, adapter):
      +        """Ampersand in URL query string must not be escaped."""
      +        result = adapter.format_message("[link](https://x.com?a=1&b=2)")
      +        assert result == "<https://x.com?a=1&b=2|link>"
      +
      +    def test_emoji_shortcodes_passthrough(self, adapter):
      +        """Emoji shortcodes like :smile: pass through unchanged."""
      +        assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
      +
      +
      +# ---------------------------------------------------------------------------
      +# TestEditMessage
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestEditMessage:
      +    """Verify that edit_message() applies mrkdwn formatting before sending."""
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_bold(self, adapter):
      +        """edit_message converts **bold** to Slack *bold*."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "1234.5678", "**hello world**")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"] == "*hello world*"
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_links(self, adapter):
      +        """edit_message converts markdown links to Slack format."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"] == "<https://example.com|click>"
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_preserves_blockquotes(self, adapter):
      +        """edit_message preserves blockquote > markers."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "1234.5678", "> quoted text")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"] == "> quoted text"
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_escapes_control_chars(self, adapter):
      +        """edit_message escapes & < > in plain text."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"] == "AT&amp;T &lt; 5 &gt; 3"
      +
      +
      +# ---------------------------------------------------------------------------
      +# TestEditMessageStreamingPipeline
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestEditMessageStreamingPipeline:
      +    """E2E: verify that sequential streaming edits all go through format_message.
      +
      +    Simulates the GatewayStreamConsumer pattern where edit_message is called
      +    repeatedly with progressively longer accumulated text.  Every call must
      +    produce properly formatted mrkdwn in the chat_update payload.
      +    """
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_streaming_updates(self, adapter):
      +        """Simulates streaming: multiple edits, each should be formatted."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +
      +        # First streaming update — bold
      +        result1 = await adapter.edit_message("C123", "ts1", "**Processing**...")
      +        assert result1.success is True
      +        kwargs1 = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs1["text"] == "*Processing*..."
      +
      +        # Second streaming update — bold + link
      +        result2 = await adapter.edit_message(
      +            "C123", "ts1", "**Done!** See [results](https://example.com)"
      +        )
      +        assert result2.success is True
      +        kwargs2 = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs2["text"] == "*Done!* See <https://example.com|results>"
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_code_and_bold(self, adapter):
      +        """Streaming update with code block and bold — code must be preserved."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +
      +        content = "**Result:**\n```python\nprint('hello')\n```"
      +        result = await adapter.edit_message("C123", "ts1", content)
      +        assert result.success is True
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"].startswith("*Result:*")
      +        assert "```python\nprint('hello')\n```" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_blockquote_in_stream(self, adapter):
      +        """Streaming update with blockquote — '>' marker must survive."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +
      +        content = "> **Important:** do this\nnormal line"
      +        result = await adapter.edit_message("C123", "ts1", content)
      +        assert result.success is True
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert kwargs["text"].startswith("> *Important:*")
      +        assert "normal line" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_progressive_accumulation(self, adapter):
      +        """Simulate real streaming: text grows with each edit, all formatted."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +
      +        updates = [
      +            ("**Step 1**", "*Step 1*"),
      +            ("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"),
      +            (
      +                "**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)",
      +                "*Step 1*\n*Step 2*\nSee <https://docs.example.com|docs>",
      +            ),
      +        ]
      +
      +        for raw, expected in updates:
      +            result = await adapter.edit_message("C123", "ts1", raw)
      +            assert result.success is True
      +            kwargs = adapter._app.client.chat_update.call_args.kwargs
      +            assert kwargs["text"] == expected, f"Failed for input: {raw!r}"
      +
      +        # Total edit count should match number of updates
      +        assert adapter._app.client.chat_update.call_count == len(updates)
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_bold_italic(self, adapter):
      +        """Bold+italic ***text*** is formatted as *_text_* in edited messages."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "ts1", "***important*** update")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert "*_important_*" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_does_not_double_escape(self, adapter):
      +        """Pre-escaped entities in edited messages must not get double-escaped."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "ts1", "5 &gt; 3 and &amp; entity")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert "&amp;gt;" not in kwargs["text"]
      +        assert "&amp;amp;" not in kwargs["text"]
      +        assert "&gt;" in kwargs["text"]
      +        assert "&amp;" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_formats_url_with_parens(self, adapter):
      +        """Wikipedia-style URL with parens survives edit pipeline."""
      +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
      +        await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
      +        kwargs = adapter._app.client.chat_update.call_args.kwargs
      +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_edit_message_not_connected(self, adapter):
      +        """edit_message returns failure when adapter is not connected."""
      +        adapter._app = None
      +        result = await adapter.edit_message("C123", "ts1", "**hello**")
      +        assert result.success is False
      +        assert "Not connected" in result.error
      +
       
       # ---------------------------------------------------------------------------
       # TestReactions
      @@ -1085,6 +1416,48 @@ async def test_short_message_single_send(self, adapter):
               await adapter.send("C123", "hello world")
               assert adapter._app.client.chat_postMessage.call_count == 1
       
      +    @pytest.mark.asyncio
      +    async def test_send_preserves_blockquote_formatting(self, adapter):
      +        """Blockquote '>' markers must survive format → chunk → send pipeline."""
      +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
      +        await adapter.send("C123", "> quoted text\nnormal text")
      +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
      +        sent_text = kwargs["text"]
      +        assert sent_text.startswith("> quoted text")
      +        assert "normal text" in sent_text
      +
      +    @pytest.mark.asyncio
      +    async def test_send_formats_bold_italic(self, adapter):
      +        """Bold+italic ***text*** is formatted as *_text_* in sent messages."""
      +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
      +        await adapter.send("C123", "***important*** update")
      +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
      +        assert "*_important_*" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_send_explicitly_enables_mrkdwn(self, adapter):
      +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
      +        await adapter.send("C123", "**hello**")
      +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
      +        assert kwargs.get("mrkdwn") is True
      +
      +    @pytest.mark.asyncio
      +    async def test_send_does_not_double_escape_entities(self, adapter):
      +        """Pre-escaped &amp; in sent messages must not become &amp;amp;."""
      +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
      +        await adapter.send("C123", "Use &amp; for ampersand")
      +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
      +        assert "&amp;amp;" not in kwargs["text"]
      +        assert "&amp;" in kwargs["text"]
      +
      +    @pytest.mark.asyncio
      +    async def test_send_formats_url_with_parens(self, adapter):
      +        """Wikipedia-style URL with parens survives send pipeline."""
      +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
      +        await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
      +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
      +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
      +
       
       # ---------------------------------------------------------------------------
       # TestReplyBroadcast
    • tests/gateway/test_slack_mention.py
      @@ -0,0 +1,312 @@
      +"""
      +Tests for Slack mention gating (require_mention / free_response_channels).
      +
      +Follows the same pattern as test_whatsapp_group_gating.py.
      +"""
      +
      +import sys
      +from unittest.mock import MagicMock
      +
      +from gateway.config import Platform, PlatformConfig
      +
      +
      +# ---------------------------------------------------------------------------
      +# Mock slack-bolt if not installed (same as test_slack.py)
      +# ---------------------------------------------------------------------------
      +
      +def _ensure_slack_mock():
      +    if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
      +        return
      +
      +    slack_bolt = MagicMock()
      +    slack_bolt.async_app.AsyncApp = MagicMock
      +    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
      +
      +    slack_sdk = MagicMock()
      +    slack_sdk.web.async_client.AsyncWebClient = MagicMock
      +
      +    for name, mod in [
      +        ("slack_bolt", slack_bolt),
      +        ("slack_bolt.async_app", slack_bolt.async_app),
      +        ("slack_bolt.adapter", slack_bolt.adapter),
      +        ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
      +        ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
      +        ("slack_sdk", slack_sdk),
      +        ("slack_sdk.web", slack_sdk.web),
      +        ("slack_sdk.web.async_client", slack_sdk.web.async_client),
      +    ]:
      +        sys.modules.setdefault(name, mod)
      +
      +
      +_ensure_slack_mock()
      +
      +import gateway.platforms.slack as _slack_mod
      +_slack_mod.SLACK_AVAILABLE = True
      +
      +from gateway.platforms.slack import SlackAdapter  # noqa: E402
      +
      +
      +# ---------------------------------------------------------------------------
      +# Helpers
      +# ---------------------------------------------------------------------------
      +
      +BOT_USER_ID = "U_BOT_123"
      +CHANNEL_ID = "C0AQWDLHY9M"
      +OTHER_CHANNEL_ID = "C9999999999"
      +
      +
      +def _make_adapter(require_mention=None, free_response_channels=None):
      +    extra = {}
      +    if require_mention is not None:
      +        extra["require_mention"] = require_mention
      +    if free_response_channels is not None:
      +        extra["free_response_channels"] = free_response_channels
      +
      +    adapter = object.__new__(SlackAdapter)
      +    adapter.platform = Platform.SLACK
      +    adapter.config = PlatformConfig(enabled=True, extra=extra)
      +    adapter._bot_user_id = BOT_USER_ID
      +    adapter._team_bot_user_ids = {}
      +    return adapter
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests: _slack_require_mention
      +# ---------------------------------------------------------------------------
      +
      +def test_require_mention_defaults_to_true(monkeypatch):
      +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
      +    adapter = _make_adapter()
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_false():
      +    adapter = _make_adapter(require_mention=False)
      +    assert adapter._slack_require_mention() is False
      +
      +
      +def test_require_mention_true():
      +    adapter = _make_adapter(require_mention=True)
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_string_true():
      +    adapter = _make_adapter(require_mention="true")
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_string_false():
      +    adapter = _make_adapter(require_mention="false")
      +    assert adapter._slack_require_mention() is False
      +
      +
      +def test_require_mention_string_no():
      +    adapter = _make_adapter(require_mention="no")
      +    assert adapter._slack_require_mention() is False
      +
      +
      +def test_require_mention_string_yes():
      +    adapter = _make_adapter(require_mention="yes")
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_empty_string_stays_true():
      +    """Empty/malformed strings keep gating ON (explicit-false parser)."""
      +    adapter = _make_adapter(require_mention="")
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_malformed_string_stays_true():
      +    """Unrecognised values keep gating ON (fail-closed)."""
      +    adapter = _make_adapter(require_mention="maybe")
      +    assert adapter._slack_require_mention() is True
      +
      +
      +def test_require_mention_env_var_fallback(monkeypatch):
      +    monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false")
      +    adapter = _make_adapter()  # no config value -> falls back to env
      +    assert adapter._slack_require_mention() is False
      +
      +
      +def test_require_mention_env_var_default_true(monkeypatch):
      +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
      +    adapter = _make_adapter()
      +    assert adapter._slack_require_mention() is True
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests: _slack_free_response_channels
      +# ---------------------------------------------------------------------------
      +
      +def test_free_response_channels_default_empty(monkeypatch):
      +    monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
      +    adapter = _make_adapter()
      +    assert adapter._slack_free_response_channels() == set()
      +
      +
      +def test_free_response_channels_list():
      +    adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
      +    result = adapter._slack_free_response_channels()
      +    assert CHANNEL_ID in result
      +    assert OTHER_CHANNEL_ID in result
      +
      +
      +def test_free_response_channels_csv_string():
      +    adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
      +    result = adapter._slack_free_response_channels()
      +    assert CHANNEL_ID in result
      +    assert OTHER_CHANNEL_ID in result
      +
      +
      +def test_free_response_channels_empty_string():
      +    adapter = _make_adapter(free_response_channels="")
      +    assert adapter._slack_free_response_channels() == set()
      +
      +
      +def test_free_response_channels_env_var_fallback(monkeypatch):
      +    monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
      +    adapter = _make_adapter()  # no config value → falls back to env
      +    result = adapter._slack_free_response_channels()
      +    assert CHANNEL_ID in result
      +    assert OTHER_CHANNEL_ID in result
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests: mention gating integration (simulating _handle_slack_message logic)
      +# ---------------------------------------------------------------------------
      +
      +def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
      +                   text="hello", mentioned=False, thread_reply=False,
      +                   active_session=False):
      +    """Simulate the mention gating logic from _handle_slack_message.
      +
      +    Returns True if the message would be processed, False if it would be
      +    skipped (returned early).
      +    """
      +    bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
      +    if mentioned:
      +        text = f"<@{bot_uid}> {text}"
      +    is_mentioned = bot_uid and f"<@{bot_uid}>" in text
      +
      +    if not is_dm:
      +        if channel_id in adapter._slack_free_response_channels():
      +            return True
      +        elif not adapter._slack_require_mention():
      +            return True
      +        elif not is_mentioned:
      +            if thread_reply and active_session:
      +                return True
      +            else:
      +                return False
      +    return True
      +
      +
      +def test_default_require_mention_channel_without_mention_ignored():
      +    adapter = _make_adapter()  # default: require_mention=True
      +    assert _would_process(adapter, text="hello everyone") is False
      +
      +
      +def test_require_mention_false_channel_without_mention_processed():
      +    adapter = _make_adapter(require_mention=False)
      +    assert _would_process(adapter, text="hello everyone") is True
      +
      +
      +def test_channel_in_free_response_processed_without_mention():
      +    adapter = _make_adapter(
      +        require_mention=True,
      +        free_response_channels=[CHANNEL_ID],
      +    )
      +    assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True
      +
      +
      +def test_other_channel_not_in_free_response_still_gated():
      +    adapter = _make_adapter(
      +        require_mention=True,
      +        free_response_channels=[CHANNEL_ID],
      +    )
      +    assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
      +
      +
      +def test_dm_always_processed_regardless_of_setting():
      +    adapter = _make_adapter(require_mention=True)
      +    assert _would_process(adapter, is_dm=True, text="hello") is True
      +
      +
      +def test_mentioned_message_always_processed():
      +    adapter = _make_adapter(require_mention=True)
      +    assert _would_process(adapter, mentioned=True, text="what's up") is True
      +
      +
      +def test_thread_reply_with_active_session_processed():
      +    adapter = _make_adapter(require_mention=True)
      +    assert _would_process(
      +        adapter, text="followup",
      +        thread_reply=True, active_session=True,
      +    ) is True
      +
      +
      +def test_thread_reply_without_active_session_ignored():
      +    adapter = _make_adapter(require_mention=True)
      +    assert _would_process(
      +        adapter, text="followup",
      +        thread_reply=True, active_session=False,
      +    ) is False
      +
      +
      +def test_bot_uid_none_processes_channel_message():
      +    """When bot_uid is None (before auth_test), channel messages pass through.
      +
      +    This preserves the old behavior: the gating block is skipped entirely
      +    when bot_uid is falsy, so messages are not silently dropped during
      +    startup or for new workspaces.
      +    """
      +    adapter = _make_adapter(require_mention=True)
      +    adapter._bot_user_id = None
      +    adapter._team_bot_user_ids = {}
      +
      +    # With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,
      +    # so the gating block is skipped — message passes through.
      +    bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
      +    assert bot_uid is None
      +
      +    # Simulate: gating block not entered when bot_uid is falsy
      +    is_dm = False
      +    if not is_dm and bot_uid:
      +        result = False  # would enter gating
      +    else:
      +        result = True  # gating skipped, message processed
      +    assert result is True
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests: config bridging
      +# ---------------------------------------------------------------------------
      +
      +def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
      +    from gateway.config import load_gateway_config
      +
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    (hermes_home / "config.yaml").write_text(
      +        "slack:\n"
      +        "  require_mention: false\n"
      +        "  free_response_channels:\n"
      +        "    - C0AQWDLHY9M\n"
      +        "    - C9999999999\n",
      +        encoding="utf-8",
      +    )
      +
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
      +    monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
      +
      +    config = load_gateway_config()
      +
      +    assert config is not None
      +    slack_extra = config.platforms[Platform.SLACK].extra
      +    assert slack_extra.get("require_mention") is False
      +    assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"]
      +    # Verify env vars were set by config bridging
      +    import os as _os
      +    assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
      +    assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
    • tests/tools/test_send_message_tool.py
      @@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):
           monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
       
       
      +def _ensure_slack_mock(monkeypatch):
      +    if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
      +        return
      +
      +    slack_bolt = MagicMock()
      +    slack_bolt.async_app.AsyncApp = MagicMock
      +    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
      +
      +    slack_sdk = MagicMock()
      +    slack_sdk.web.async_client.AsyncWebClient = MagicMock
      +
      +    for name, mod in [
      +        ("slack_bolt", slack_bolt),
      +        ("slack_bolt.async_app", slack_bolt.async_app),
      +        ("slack_bolt.adapter", slack_bolt.adapter),
      +        ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
      +        ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
      +        ("slack_sdk", slack_sdk),
      +        ("slack_sdk.web", slack_sdk.web),
      +        ("slack_sdk.web.async_client", slack_sdk.web.async_client),
      +    ]:
      +        monkeypatch.setitem(sys.modules, name, mod)
      +
      +
       class TestSendMessageTool:
           def test_cron_duplicate_target_is_skipped_and_explained(self):
               home = SimpleNamespace(chat_id="-1001")
      @@ -426,7 +450,7 @@ def test_long_message_is_chunked(self):
                   result = asyncio.run(
                       _send_to_platform(
                           Platform.DISCORD,
      -                    SimpleNamespace(enabled=True, token="tok", extra={}),
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
                           "ch", long_msg,
                       )
                   )
      @@ -435,8 +459,115 @@ def test_long_message_is_chunked(self):
               for call in send.await_args_list:
                   assert len(call.args[2]) <= 2020  # each chunk fits the limit
       
      +    def test_slack_messages_are_formatted_before_send(self, monkeypatch):
      +        _ensure_slack_mock(monkeypatch)
      +
      +        import gateway.platforms.slack as slack_mod
      +
      +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
      +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
      +
      +        with patch("tools.send_message_tool._send_slack", send):
      +            result = asyncio.run(
      +                _send_to_platform(
      +                    Platform.SLACK,
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
      +                    "C123",
      +                    "**hello** from [Hermes](<https://example.com>)",
      +                )
      +            )
      +
      +        assert result["success"] is True
      +        send.assert_awaited_once_with(
      +            "***",
      +            "C123",
      +            "*hello* from <https://example.com|Hermes>",
      +        )
      +
      +    def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
      +        """Bold+italic ***text*** survives tool-layer formatting."""
      +        _ensure_slack_mock(monkeypatch)
      +        import gateway.platforms.slack as slack_mod
      +
      +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
      +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
      +        with patch("tools.send_message_tool._send_slack", send):
      +            result = asyncio.run(
      +                _send_to_platform(
      +                    Platform.SLACK,
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
      +                    "C123",
      +                    "***important*** update",
      +                )
      +            )
      +        assert result["success"] is True
      +        sent_text = send.await_args.args[2]
      +        assert "*_important_*" in sent_text
      +
      +    def test_slack_blockquote_formatted_before_send(self, monkeypatch):
      +        """Blockquote '>' markers must survive formatting (not escaped to '&gt;')."""
      +        _ensure_slack_mock(monkeypatch)
      +        import gateway.platforms.slack as slack_mod
      +
      +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
      +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
      +        with patch("tools.send_message_tool._send_slack", send):
      +            result = asyncio.run(
      +                _send_to_platform(
      +                    Platform.SLACK,
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
      +                    "C123",
      +                    "> important quote\n\nnormal text & stuff",
      +                )
      +            )
      +        assert result["success"] is True
      +        sent_text = send.await_args.args[2]
      +        assert sent_text.startswith("> important quote")
      +        assert "&amp;" in sent_text  # & is escaped
      +        assert "&gt;" not in sent_text.split("\n")[0]  # > in blockquote is NOT escaped
      +
      +    def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
      +        """Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
      +        _ensure_slack_mock(monkeypatch)
      +        import gateway.platforms.slack as slack_mod
      +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
      +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
      +        with patch("tools.send_message_tool._send_slack", send):
      +            result = asyncio.run(
      +                _send_to_platform(
      +                    Platform.SLACK,
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
      +                    "C123",
      +                    "AT&amp;T &lt;tag&gt; test",
      +                )
      +            )
      +        assert result["success"] is True
      +        sent_text = send.await_args.args[2]
      +        assert "&amp;amp;" not in sent_text
      +        assert "&amp;lt;" not in sent_text
      +        assert "AT&amp;T" in sent_text
      +
      +    def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
      +        """Wikipedia-style URL with parens survives tool-layer formatting."""
      +        _ensure_slack_mock(monkeypatch)
      +        import gateway.platforms.slack as slack_mod
      +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
      +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
      +        with patch("tools.send_message_tool._send_slack", send):
      +            result = asyncio.run(
      +                _send_to_platform(
      +                    Platform.SLACK,
      +                    SimpleNamespace(enabled=True, token="***", extra={}),
      +                    "C123",
      +                    "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
      +                )
      +            )
      +        assert result["success"] is True
      +        sent_text = send.await_args.args[2]
      +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
      +
           def test_telegram_media_attaches_to_last_chunk(self):
      -        """When chunked, media files are sent only with the last chunk."""
      +
               sent_calls = []
       
               async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
    • tools/send_message_tool.py
      @@ -322,6 +322,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
       
           media_files = media_files or []
       
      +    if platform == Platform.SLACK and message:
      +        try:
      +            slack_adapter = SlackAdapter.__new__(SlackAdapter)
      +            message = slack_adapter.format_message(message)
      +        except Exception:
      +            logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
      +
           # Platform message length limits (from adapter class attributes)
           _MAX_LENGTHS = {
               Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,
      @@ -571,7 +578,8 @@ async def _send_slack(token, chat_id, message):
               url = "https://slack.com/api/chat.postMessage"
               headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
               async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
      -            async with session.post(url, headers=headers, json={"channel": chat_id, "text": message}) as resp:
      +            payload = {"channel": chat_id, "text": message, "mrkdwn": True}
      +            async with session.post(url, headers=headers, json=payload) as resp:
                       data = await resp.json()
                       if data.get("ok"):
                           return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}

2 PRs closed without merging

From the close comments: merged via another change 2

  • #6268 fix(slack): respect require_mention config for channel messages · merged via another change → #6809, #5885
  • #6303 fix(gateway): read require_mention from config.extra for Slack adapter · merged via another change → #6809, #5885
Closed issues · 1
  • Closed#6218Slack adapter ignores require_mention: false config for channel messages
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#6809
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
9 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.