feat: Slack adapter improvements — formatting, reactions, user resolution, commands (#1106)

feat: Slack adapter improvements — formatting, reactions, user resolution, commands
This commit is contained in:
Teknium
2026-03-12 16:35:44 -07:00
committed by GitHub
2 changed files with 473 additions and 12 deletions

View File

@@ -66,13 +66,14 @@ class SlackAdapter(BasePlatformAdapter):
- Typing indicators (not natively supported by Slack bots)
"""
MAX_MESSAGE_LENGTH = 4000 # Slack's limit is higher but mrkdwn can inflate
MAX_MESSAGE_LENGTH = 3900 # Slack hard limit is 4000 but leave room for mrkdwn
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
self._app: Optional[AsyncApp] = None
self._handler: Optional[AsyncSocketModeHandler] = None
self._bot_user_id: Optional[str] = None
self._user_name_cache: Dict[str, str] = {} # user_id → display name
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -152,22 +153,36 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
kwargs = {
"channel": chat_id,
"text": content,
}
# Convert standard markdown → Slack mrkdwn
formatted = self.format_message(content)
# Split long messages, preserving code block boundaries
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
# Reply in thread if thread context is available.
thread_ts = self._resolve_thread_ts(reply_to, metadata)
if thread_ts:
kwargs["thread_ts"] = thread_ts
last_result = None
result = await self._app.client.chat_postMessage(**kwargs)
# reply_broadcast: also post thread replies to the main channel.
# Controlled via platform config: gateway.slack.reply_broadcast
broadcast = self.config.extra.get("reply_broadcast", False)
for i, chunk in enumerate(chunks):
kwargs = {
"channel": chat_id,
"text": chunk,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
# Only broadcast the first chunk of the first reply
if broadcast and i == 0:
kwargs["reply_broadcast"] = True
last_result = await self._app.client.chat_postMessage(**kwargs)
return SendResult(
success=True,
message_id=result.get("ts"),
raw_response=result,
message_id=last_result.get("ts") if last_result else None,
raw_response=last_result,
)
except Exception as e: # pragma: no cover - defensive logging
@@ -201,7 +216,11 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error=str(e))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Slack doesn't have a direct typing indicator API for bots."""
"""Slack doesn't support typing indicators for bot users.
The reactions system (👀 on receipt, ✅ on completion) serves as
the visual feedback mechanism instead.
"""
pass
def _resolve_thread_ts(
@@ -221,6 +240,154 @@ class SlackAdapter(BasePlatformAdapter):
return metadata["thread_ts"]
return reply_to
# ----- Markdown → mrkdwn conversion -----
def format_message(self, content: str) -> str:
"""Convert standard markdown to Slack mrkdwn format.
Protected regions (code blocks, inline code) are extracted first so
their contents are never modified. Standard markdown constructs
(headers, bold, italic, links) are translated to mrkdwn syntax.
"""
if not content:
return content
placeholders: dict = {}
counter = [0]
def _ph(value: str) -> str:
"""Stash value behind a placeholder that survives later passes."""
key = f"\x00SL{counter[0]}\x00"
counter[0] += 1
placeholders[key] = value
return key
text = content
# 1) Protect fenced code blocks (``` ... ```)
text = re.sub(
r'(```(?:[^\n]*\n)?[\s\S]*?```)',
lambda m: _ph(m.group(0)),
text,
)
# 2) Protect inline code (`...`)
text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
# 3) Convert markdown links [text](url) → <url|text>
text = re.sub(
r'\[([^\]]+)\]\(([^)]+)\)',
lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
text,
)
# 4) Convert headers (## Title) → *Title* (bold)
def _convert_header(m):
inner = m.group(1).strip()
# Strip redundant bold markers inside a header
inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner)
return _ph(f'*{inner}*')
text = re.sub(
r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
)
# 5) 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)
text = re.sub(
r'(?<!\*)\*([^*\n]+)\*(?!\*)',
lambda m: _ph(f'_{m.group(1)}_'),
text,
)
# 7) 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.
# 9) Restore placeholders in reverse order
for key in reversed(list(placeholders.keys())):
text = text.replace(key, placeholders[key])
return text
# ----- Reactions -----
async def _add_reaction(
self, channel: str, timestamp: str, emoji: str
) -> bool:
"""Add an emoji reaction to a message. Returns True on success."""
if not self._app:
return False
try:
await self._app.client.reactions_add(
channel=channel, timestamp=timestamp, name=emoji
)
return True
except Exception as e:
# Don't log as error — may fail if already reacted or missing scope
logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e)
return False
async def _remove_reaction(
self, channel: str, timestamp: str, emoji: str
) -> bool:
"""Remove an emoji reaction from a message. Returns True on success."""
if not self._app:
return False
try:
await self._app.client.reactions_remove(
channel=channel, timestamp=timestamp, name=emoji
)
return True
except Exception as e:
logger.debug("[Slack] reactions.remove failed (%s): %s", emoji, e)
return False
# ----- User identity resolution -----
async def _resolve_user_name(self, user_id: str) -> str:
"""Resolve a Slack user ID to a display name, with caching."""
if not user_id:
return ""
if user_id in self._user_name_cache:
return self._user_name_cache[user_id]
if not self._app:
return user_id
try:
result = await self._app.client.users_info(user=user_id)
user = result.get("user", {})
# Prefer display_name → real_name → user_id
profile = user.get("profile", {})
name = (
profile.get("display_name")
or profile.get("real_name")
or user.get("real_name")
or user.get("name")
or user_id
)
self._user_name_cache[user_id] = name
return name
except Exception as e:
logger.debug("[Slack] users.info failed for %s: %s", user_id, e)
self._user_name_cache[user_id] = user_id
return user_id
async def send_image_file(
self,
chat_id: str,
@@ -551,12 +718,16 @@ class SlackAdapter(BasePlatformAdapter):
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
# Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id)
# Build source
source = self.build_source(
chat_id=channel_id,
chat_name=channel_id, # Will be resolved later if needed
chat_type="dm" if is_dm else "group",
user_id=user_id,
user_name=user_name,
thread_id=thread_ts,
)
@@ -571,8 +742,15 @@ 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")
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")
async def _handle_slash_command(self, command: dict) -> None:
"""Handle /hermes slash command."""
text = command.get("text", "").strip()
@@ -586,6 +764,15 @@ class SlackAdapter(BasePlatformAdapter):
"help": "/help",
"model": "/model", "personality": "/personality",
"retry": "/retry", "undo": "/undo",
"compact": "/compress", "compress": "/compress",
"resume": "/resume",
"background": "/background",
"usage": "/usage",
"insights": "/insights",
"title": "/title",
"reasoning": "/reasoning",
"provider": "/provider",
"rollback": "/rollback",
}
first_word = text.split()[0] if text else ""
if first_word in subcommand_map:

View File

@@ -530,3 +530,277 @@ class TestMessageRouting:
}
await adapter._handle_slack_message(event)
adapter.handle_message.assert_not_called()
# ---------------------------------------------------------------------------
# TestFormatMessage — Markdown → mrkdwn conversion
# ---------------------------------------------------------------------------
class TestFormatMessage:
"""Test markdown to Slack mrkdwn conversion."""
def test_bold_conversion(self, adapter):
assert adapter.format_message("**hello**") == "*hello*"
def test_italic_asterisk_conversion(self, adapter):
assert adapter.format_message("*hello*") == "_hello_"
def test_italic_underscore_preserved(self, adapter):
assert adapter.format_message("_hello_") == "_hello_"
def test_header_to_bold(self, adapter):
assert adapter.format_message("## Section Title") == "*Section Title*"
def test_header_with_bold_content(self, adapter):
# **bold** inside a header should not double-wrap
assert adapter.format_message("## **Title**") == "*Title*"
def test_link_conversion(self, adapter):
result = adapter.format_message("[click here](https://example.com)")
assert result == "<https://example.com|click here>"
def test_strikethrough(self, adapter):
assert adapter.format_message("~~deleted~~") == "~deleted~"
def test_code_block_preserved(self, adapter):
code = "```python\nx = **not bold**\n```"
assert adapter.format_message(code) == code
def test_inline_code_preserved(self, adapter):
text = "Use `**raw**` syntax"
assert adapter.format_message(text) == "Use `**raw**` syntax"
def test_mixed_content(self, adapter):
text = "**Bold** and *italic* with `code`"
result = adapter.format_message(text)
assert "*Bold*" in result
assert "_italic_" in result
assert "`code`" in result
def test_empty_string(self, adapter):
assert adapter.format_message("") == ""
def test_none_passthrough(self, adapter):
assert adapter.format_message(None) is None
# ---------------------------------------------------------------------------
# TestReactions
# ---------------------------------------------------------------------------
class TestReactions:
"""Test emoji reaction methods."""
@pytest.mark.asyncio
async def test_add_reaction_calls_api(self, adapter):
adapter._app.client.reactions_add = AsyncMock()
result = await adapter._add_reaction("C123", "ts1", "eyes")
assert result is True
adapter._app.client.reactions_add.assert_called_once_with(
channel="C123", timestamp="ts1", name="eyes"
)
@pytest.mark.asyncio
async def test_add_reaction_handles_error(self, adapter):
adapter._app.client.reactions_add = AsyncMock(side_effect=Exception("already_reacted"))
result = await adapter._add_reaction("C123", "ts1", "eyes")
assert result is False
@pytest.mark.asyncio
async def test_remove_reaction_calls_api(self, adapter):
adapter._app.client.reactions_remove = AsyncMock()
result = await adapter._remove_reaction("C123", "ts1", "eyes")
assert result is True
@pytest.mark.asyncio
async def test_reactions_in_message_flow(self, adapter):
"""Reactions should be added on receipt and swapped on completion."""
adapter._app.client.reactions_add = AsyncMock()
adapter._app.client.reactions_remove = AsyncMock()
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
event = {
"text": "hello",
"user": "U_USER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
# Should have added 👀, then removed 👀, then added ✅
add_calls = adapter._app.client.reactions_add.call_args_list
remove_calls = adapter._app.client.reactions_remove.call_args_list
assert len(add_calls) == 2
assert add_calls[0].kwargs["name"] == "eyes"
assert add_calls[1].kwargs["name"] == "white_check_mark"
assert len(remove_calls) == 1
assert remove_calls[0].kwargs["name"] == "eyes"
# ---------------------------------------------------------------------------
# TestUserNameResolution
# ---------------------------------------------------------------------------
class TestUserNameResolution:
"""Test user identity resolution."""
@pytest.mark.asyncio
async def test_resolves_display_name(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}}
})
name = await adapter._resolve_user_name("U123")
assert name == "Tyler"
@pytest.mark.asyncio
async def test_falls_back_to_real_name(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "", "real_name": "Tyler B"}}
})
name = await adapter._resolve_user_name("U123")
assert name == "Tyler B"
@pytest.mark.asyncio
async def test_caches_result(self, adapter):
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
await adapter._resolve_user_name("U123")
await adapter._resolve_user_name("U123")
# Only one API call despite two lookups
assert adapter._app.client.users_info.call_count == 1
@pytest.mark.asyncio
async def test_handles_api_error(self, adapter):
adapter._app.client.users_info = AsyncMock(side_effect=Exception("rate limited"))
name = await adapter._resolve_user_name("U123")
assert name == "U123" # Falls back to user_id
@pytest.mark.asyncio
async def test_user_name_in_message_source(self, adapter):
"""Message source should include resolved user name."""
adapter._app.client.users_info = AsyncMock(return_value={
"user": {"profile": {"display_name": "Tyler"}}
})
adapter._app.client.reactions_add = AsyncMock()
adapter._app.client.reactions_remove = AsyncMock()
event = {
"text": "hello",
"user": "U_USER",
"channel": "C123",
"channel_type": "im",
"ts": "1234567890.000001",
}
await adapter._handle_slack_message(event)
# Check the source in the MessageEvent passed to handle_message
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.user_name == "Tyler"
# ---------------------------------------------------------------------------
# TestSlashCommands — expanded command set
# ---------------------------------------------------------------------------
class TestSlashCommands:
"""Test slash command routing."""
@pytest.mark.asyncio
async def test_compact_maps_to_compress(self, adapter):
command = {"text": "compact", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/compress"
@pytest.mark.asyncio
async def test_resume_command(self, adapter):
command = {"text": "resume my session", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/resume my session"
@pytest.mark.asyncio
async def test_background_command(self, adapter):
command = {"text": "background run tests", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/background run tests"
@pytest.mark.asyncio
async def test_usage_command(self, adapter):
command = {"text": "usage", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/usage"
@pytest.mark.asyncio
async def test_reasoning_command(self, adapter):
command = {"text": "reasoning", "user_id": "U1", "channel_id": "C1"}
await adapter._handle_slash_command(command)
msg = adapter.handle_message.call_args[0][0]
assert msg.text == "/reasoning"
# ---------------------------------------------------------------------------
# TestMessageSplitting
# ---------------------------------------------------------------------------
class TestMessageSplitting:
"""Test that long messages are split before sending."""
@pytest.mark.asyncio
async def test_long_message_split_into_chunks(self, adapter):
"""Messages over MAX_MESSAGE_LENGTH should be split."""
long_text = "x" * 5000
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", long_text)
# Should have been called multiple times
assert adapter._app.client.chat_postMessage.call_count >= 2
@pytest.mark.asyncio
async def test_short_message_single_send(self, adapter):
"""Short messages should be sent in one call."""
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hello world")
assert adapter._app.client.chat_postMessage.call_count == 1
# ---------------------------------------------------------------------------
# TestReplyBroadcast
# ---------------------------------------------------------------------------
class TestReplyBroadcast:
"""Test reply_broadcast config option."""
@pytest.mark.asyncio
async def test_broadcast_disabled_by_default(self, adapter):
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"})
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert "reply_broadcast" not in kwargs
@pytest.mark.asyncio
async def test_broadcast_enabled_via_config(self, adapter):
adapter.config.extra["reply_broadcast"] = True
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ts": "ts1"}
)
await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"})
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert kwargs.get("reply_broadcast") is True