Compare commits

...

7 Commits

Author SHA1 Message Date
jarvisxyz
d12f8a157c fix(slack): add rate-limit retry and TTL cache to thread context fetching
- Add _ThreadContextCache dataclass for caching fetched context (60s TTL)
- Add exponential backoff retry for conversations.replies 429 rate limits
  (Tier 3, ~50 req/min)
- Only fetch context when no active session exists (guard at call site)
  to prevent duplication across turns
- Hoist bot_uid lookup outside the per-message loop
- Clearer header text for injected thread context

Based on PR #6162 by jarvisxyz, cherry-picked onto current main.
2026-04-09 13:37:15 -07:00
gunpowder-client-vm
8d914eba26 fix(slack): treat group DMs (mpim) like DMs + smart reaction guard
- Treat mpim (multi-party IM / group DM) channels as DMs — no @mention
  required, continuous session like 1:1 DMs
- Only add 👀/ reactions when bot is directly addressed (DM or
  @mention). In listen-all channels (require_mention=false) reacting
  to every message would be noisy.

Based on PR #4633 by gunpowder-client-vm, adapted to current main.
2026-04-09 13:36:04 -07:00
Mibayy
c142d1884b feat(slack): add allow_bots config for bot-to-bot communication
Three modes: "none" (default, backward-compatible), "mentions" (accept
bot messages only when they @mention us), "all" (accept all bot messages
except our own, to prevent echo loops).

Configurable via:
  slack:
    allow_bots: mentions
Or env var: SLACK_ALLOW_BOTS=mentions

Self-message guard always active regardless of mode.

Based on PR #3200 by Mibayy, adapted to current main with config.yaml
bridging support.
2026-04-09 13:35:13 -07:00
dashed
7f560a72b0 fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.

Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
  escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration

Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 13:33:05 -07:00
Doruk Ardahan
68a6ca03dc feat(slack): add require_mention and free_response_channels config support
Port the mention gating pattern from Telegram, Discord, WhatsApp, and
Matrix adapters to the Slack platform adapter.

- Add _slack_require_mention() with explicit-false parsing and env var
  fallback (SLACK_REQUIRE_MENTION)
- Add _slack_free_response_channels() with env var fallback
  (SLACK_FREE_RESPONSE_CHANNELS)
- Replace hardcoded mention check with configurable gating logic
- Bridge slack config.yaml settings to env vars
- Bridge free_response_channels through the generic platform bridging loop
- Add 26 tests covering config parsing, env fallback, gating logic

Config usage:
  slack:
    require_mention: false
    free_response_channels:
      - "C0AQWDLHY9M"

Default behavior unchanged: channels require @mention (backward compatible).

Based on PR #5885 by dorukardahan, cherry-picked and adapted to current main.
2026-04-09 13:31:36 -07:00
Teknium
f82092948f fix(security): enforce user authorization on approval button clicks
Approval button clicks (Block Kit actions in Slack, CallbackQuery in
Telegram) bypass the normal message authorization flow in gateway/run.py.
Any workspace/group member who can see the approval message could click
Approve to authorize dangerous commands.

Read SLACK_ALLOWED_USERS / TELEGRAM_ALLOWED_USERS env vars directly in
the approval handlers. When an allowlist is configured and the clicking
user is not in it, the click is silently ignored (Slack) or answered
with an error (Telegram). Wildcard '*' permits all users. When no
allowlist is configured, behavior is unchanged (open access).

Based on the idea from PR #6735 by maymuneth, reimplemented to use the
existing env-var-based authorization system rather than a nonexistent
_allowed_user_ids adapter attribute.
2026-04-09 13:26:37 -07:00
aaronagent
c0f350c119 fix: atomic Slack approval guard, safe JSON deserialization fallbacks
1. gateway/platforms/slack.py: Replace check-then-set TOCTOU race on
   _approval_resolved with atomic dict.pop(). Two concurrent button
   clicks could both pass the guard before either set it to True,
   causing double resolve_gateway_approval — which can resolve the
   WRONG queued approval when multiple are pending for the same session.

2. hermes_state.py: Add WARNING log and proper fallbacks when
   json.loads fails on tool_calls (→ []), reasoning_details (→ None),
   and codex_reasoning_items (→ None). Previously, failures were
   silently swallowed: tool_calls stayed as a raw string (iterating
   yields characters, not objects), and reasoning fields were simply
   missing from the dict.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:24:22 -07:00
8 changed files with 1078 additions and 72 deletions

View File

@@ -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):

View File

@@ -14,6 +14,7 @@ import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Any, Tuple
try:
@@ -45,6 +46,14 @@ from gateway.platforms.base import (
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 @@ class SlackAdapter(BasePlatformAdapter):
# 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 @@ class SlackAdapter(BasePlatformAdapter):
kwargs = {
"channel": chat_id,
"text": chunk,
"mrkdwn": True,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
@@ -323,9 +336,7 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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'\[([^\]]+)\]\(([^)]+)\)',
lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
_convert_markdown_link,
text,
)
# 4) Convert headers (## Title) → *Title* (bold)
# 4) Protect existing Slack entities/manual links so escaping and later
# formatting passes don't break them.
text = re.sub(
r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)',
lambda m: _ph(m.group(1)),
text,
)
# 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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
}
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 @@ class SlackAdapter(BasePlatformAdapter):
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 @@ class SlackAdapter(BasePlatformAdapter):
"""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 @@ class SlackAdapter(BasePlatformAdapter):
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()

View File

@@ -1398,6 +1398,15 @@ class TelegramAdapter(BasePlatformAdapter):
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.")

View File

@@ -944,7 +944,8 @@ class SessionDB:
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 @@ class SessionDB:
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 @@ class SessionDB:
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

View File

@@ -619,6 +619,18 @@ class TestFormatMessage:
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 @@ class TestFormatMessage:
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 @@ class TestMessageSplitting:
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

View File

@@ -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"

View File

@@ -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 @@ class TestSendToPlatformChunking:
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 @@ class TestSendToPlatformChunking:
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):

View File

@@ -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")}