* feat: add optional smart model routing Add a conservative cheap-vs-strong routing option that can send very short/simple turns to a cheaper model across providers while keeping the primary model for complex work. Wire it through CLI, gateway, and cron, and document the config.yaml workflow. * fix(gateway): remove recursive ExecStop from systemd units, extend TimeoutStopSec to 60s * fix(gateway): avoid recursive ExecStop in user systemd unit * fix: extend ExecStop removal and TimeoutStopSec=60 to system unit The cherry-picked PR #1448 fix only covered the user systemd unit. The system unit had the same TimeoutStopSec=15 and could benefit from the same 60s timeout for clean shutdown. Also adds a regression test for the system unit. --------- Co-authored-by: Ninja <ninja@local> * feat(skills): add blender-mcp optional skill for 3D modeling Control a running Blender instance from Hermes via socket connection to the blender-mcp addon (port 9876). Supports creating 3D objects, materials, animations, and running arbitrary bpy code. Placed in optional-skills/ since it requires Blender 4.3+ desktop with a third-party addon manually started each session. * feat(acp): support slash commands in ACP adapter (#1532) Adds /help, /model, /tools, /context, /reset, /compact, /version to the ACP adapter (VS Code, Zed, JetBrains). Commands are handled directly in the server without instantiating the TUI — each command queries agent/session state and returns plain text. Unrecognized /commands fall through to the LLM as normal messages. /model uses detect_provider_for_model() for auto-detection when switching models, matching the CLI and gateway behavior. Fixes #1402 * fix(logging): improve error logging in session search tool (#1533) * fix(gateway): restart on retryable startup failures (#1517) * feat(email): add skip_attachments option via config.yaml * feat(email): add skip_attachments option via config.yaml Adds a config.yaml-driven option to skip email attachments in the gateway email adapter. Useful for malware protection and bandwidth savings. Configure in config.yaml: platforms: email: skip_attachments: true Based on PR #1521 by @an420eth, changed from env var to config.yaml (via PlatformConfig.extra) to match the project's config-first pattern. * docs: document skip_attachments option for email adapter * fix(telegram): retry on transient TLS failures during connect and send Add exponential-backoff retry (3 attempts) around initialize() to handle transient TLS resets during gateway startup. Also catches TimedOut and OSError in addition to NetworkError. Add exponential-backoff retry (3 attempts) around send_message() for NetworkError during message delivery, wrapping the existing Markdown fallback logic. Both imports are guarded with try/except ImportError for test environments where telegram is mocked. Based on PR #1527 by cmd8. Closes #1526. * feat: permissive block_anchor thresholds and unicode normalization (#1539) Salvaged from PR #1528 by an420eth. Closes #517. Improves _strategy_block_anchor in fuzzy_match.py: - Add unicode normalization (smart quotes, em/en-dashes, ellipsis, non-breaking spaces → ASCII) so LLM-produced unicode artifacts don't break anchor line matching - Lower thresholds: 0.10 for unique matches (was 0.70), 0.30 for multiple candidates — if first/last lines match exactly, the block is almost certainly correct - Use original (non-normalized) content for offset calculation to preserve correct character positions Tested: 3 new scenarios fixed (em-dash anchors, non-breaking space anchors, very-low-similarity unique matches), zero regressions on all 9 existing fuzzy match tests. Co-authored-by: an420eth <an420eth@users.noreply.github.com> * feat(cli): add file path autocomplete in the input prompt (#1545) When typing a path-like token (./ ../ ~/ / or containing /), the CLI now shows filesystem completions in the dropdown menu. Directories show a trailing slash and 'dir' label; files show their size. Completions are case-insensitive and capped at 30 entries. Triggered by tokens like: edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ... check ~/doc → shows ~/docs/, ~/documents/, ... read /etc/hos → shows /etc/hosts, /etc/hostname, ... open tools/reg → shows tools/registry.py Slash command autocomplete (/help, /model, etc.) is unaffected — it still triggers when the input starts with /. Inspired by OpenCode PR #145 (file path completion menu). Implementation: - hermes_cli/commands.py: _extract_path_word() detects path-like tokens, _path_completions() yields filesystem Completions with size labels, get_completions() routes to paths vs slash commands - tests/hermes_cli/test_path_completion.py: 26 tests covering path extraction, prefix filtering, directory markers, home expansion, case-insensitivity, integration with slash commands * feat(privacy): redact PII from LLM context when privacy.redact_pii is enabled Add privacy.redact_pii config option (boolean, default false). When enabled, the gateway redacts personally identifiable information from the system prompt before sending it to the LLM provider: - Phone numbers (user IDs on WhatsApp/Signal) → hashed to user_<sha256> - User IDs → hashed to user_<sha256> - Chat IDs → numeric portion hashed, platform prefix preserved - Home channel IDs → hashed - Names/usernames → NOT affected (user-chosen, publicly visible) Hashes are deterministic (same user → same hash) so the model can still distinguish users in group chats. Routing and delivery use the original values internally — redaction only affects LLM context. Inspired by OpenClaw PR #47959. * fix(privacy): skip PII redaction on Discord/Slack (mentions need real IDs) Discord uses <@user_id> for mentions and Slack uses <@U12345> — the LLM needs the real ID to tag users. Redaction now only applies to WhatsApp, Signal, and Telegram where IDs are pure routing metadata. Add 4 platform-specific tests covering Discord, WhatsApp, Signal, Slack. * feat: smart approvals + /stop command (inspired by OpenAI Codex) * feat: smart approvals — LLM-based risk assessment for dangerous commands Adds a 'smart' approval mode that uses the auxiliary LLM to assess whether a flagged command is genuinely dangerous or a false positive, auto-approving low-risk commands without prompting the user. Inspired by OpenAI Codex's Smart Approvals guardian subagent (openai/codex#13860). Config (config.yaml): approvals: mode: manual # manual (default), smart, off Modes: - manual — current behavior, always prompt the user - smart — aux LLM evaluates risk: APPROVE (auto-allow), DENY (block), or ESCALATE (fall through to manual prompt) - off — skip all approval prompts (equivalent to --yolo) When smart mode auto-approves, the pattern gets session-level approval so subsequent uses of the same pattern don't trigger another LLM call. When it denies, the command is blocked without user prompt. When uncertain, it escalates to the normal manual approval flow. The LLM prompt is carefully scoped: it sees only the command text and the flagged reason, assesses actual risk vs false positive, and returns a single-word verdict. * feat: make smart approval model configurable via config.yaml Adds auxiliary.approval section to config.yaml with the same provider/model/base_url/api_key pattern as other aux tasks (vision, web_extract, compression, etc.). Config: auxiliary: approval: provider: auto model: '' # fast/cheap model recommended base_url: '' api_key: '' Bridged to env vars in both CLI and gateway paths so the aux client picks them up automatically. * feat: add /stop command to kill all background processes Adds a /stop slash command that kills all running background processes at once. Currently users have to process(list) then process(kill) for each one individually. Inspired by OpenAI Codex's separation of interrupt (Ctrl+C stops current turn) from /stop (cleans up background processes). See openai/codex#14602. Ctrl+C continues to only interrupt the active agent turn — background dev servers, watchers, etc. are preserved. /stop is the explicit way to clean them all up. * feat: first-class plugin architecture + hide status bar cost by default (#1544) The persistent status bar now shows context %, token counts, and duration but NOT $ cost by default. Cost display is opt-in via: display: show_cost: true in config.yaml, or: hermes config set display.show_cost true The /usage command still shows full cost breakdown since the user explicitly asked for it — this only affects the always-visible bar. Status bar without cost: ⚕ claude-sonnet-4 │ 12K/200K │ 6% │ 15m Status bar with show_cost: true: ⚕ claude-sonnet-4 │ 12K/200K │ 6% │ $0.06 │ 15m * feat: improve memory prioritization + aggressive skill updates (inspired by OpenAI Codex) * feat: improve memory prioritization — user preferences over procedural knowledge Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493) which focus memory writes on user preferences and recurring patterns rather than procedural task details. Key insight: 'Optimize for reducing future user steering — the most valuable memory prevents the user from having to repeat themselves.' Changes: - MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy and the core principle about reducing user steering - MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put corrections first, added explicit PRIORITY guidance - Memory nudge (run_agent.py): now asks specifically about preferences, corrections, and workflow patterns instead of generic 'anything' - Memory flush (run_agent.py): now instructs to prioritize user preferences and corrections over task-specific details * feat: more aggressive skill creation and update prompting Press harder on skill updates — the agent should proactively patch skills when it encounters issues during use, not wait to be asked. Changes: - SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction to patch skills immediately when found outdated/wrong - Skills header: added instruction to update loaded skills before finishing if they had missing steps or wrong commands - Skill nudge: more assertive ('save the approach' not 'consider saving'), now also prompts for updating existing skills used in the task - Skill nudge interval: lowered default from 15 to 10 iterations - skill_manage schema: added 'patch it immediately' to update triggers * feat: first-class plugin architecture (#1555) Plugin system for extending Hermes with custom tools, hooks, and integrations — no source code changes required. Core system (hermes_cli/plugins.py): - Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and pip entry_points (hermes_agent.plugins group) - PluginContext with register_tool() and register_hook() - 6 lifecycle hooks: pre/post tool_call, pre/post llm_call, on_session_start/end - Namespace package handling for relative imports in plugins - Graceful error isolation — broken plugins never crash the agent Integration (model_tools.py): - Plugin discovery runs after built-in + MCP tools - Plugin tools bypass toolset filter via get_plugin_tool_names() - Pre/post tool call hooks fire in handle_function_call() CLI: - /plugins command shows loaded plugins, tool counts, status - Added to COMMANDS dict for autocomplete Docs: - Getting started guide (build-a-hermes-plugin.md) — full tutorial building a calculator plugin step by step - Reference page (features/plugins.md) — quick overview + tables - Covers: file structure, schemas, handlers, hooks, data files, bundled skills, env var gating, pip distribution, common mistakes Tests: 16 tests covering discovery, loading, hooks, tool visibility. * feat: add /bg as alias for /background slash command Adds /bg alias across CLI, gateway, and Slack platform adapter. Updates help text, autocomplete, known_commands set, and dispatch logic. Includes tests for the new alias. * docs: add plan for centralized slash command registry Scopes a refactor to replace 7+ scattered command definition sites with a single CommandDef registry in hermes_cli/commands.py. Includes derived helper functions for gateway help text, Telegram BotCommands, Slack subcommand maps, and alias resolution. Documents current drift (Telegram missing /rollback + /background, Slack missing /voice + /update, gateway dead code) that the refactor fixes for free. --------- Co-authored-by: Ninja <ninja@local> Co-authored-by: alireza78a <alireza78a@users.noreply.github.com> Co-authored-by: Oktay Aydin <113846926+aydnOktay@users.noreply.github.com> Co-authored-by: JP Lew <polydegen@protonmail.com> Co-authored-by: an420eth <an420eth@users.noreply.github.com>
866 lines
32 KiB
Python
866 lines
32 KiB
Python
"""
|
|
Slack platform adapter.
|
|
|
|
Uses slack-bolt (Python) with Socket Mode for:
|
|
- Receiving messages from channels and DMs
|
|
- Sending responses back
|
|
- Handling slash commands
|
|
- Thread support
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
try:
|
|
from slack_bolt.async_app import AsyncApp
|
|
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
SLACK_AVAILABLE = True
|
|
except ImportError:
|
|
SLACK_AVAILABLE = False
|
|
AsyncApp = Any
|
|
AsyncSocketModeHandler = Any
|
|
AsyncWebClient = Any
|
|
|
|
import sys
|
|
from pathlib import Path as _Path
|
|
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
SendResult,
|
|
SUPPORTED_DOCUMENT_TYPES,
|
|
cache_document_from_bytes,
|
|
cache_image_from_url,
|
|
cache_audio_from_url,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def check_slack_requirements() -> bool:
|
|
"""Check if Slack dependencies are available."""
|
|
return SLACK_AVAILABLE
|
|
|
|
|
|
class SlackAdapter(BasePlatformAdapter):
|
|
"""
|
|
Slack bot adapter using Socket Mode.
|
|
|
|
Requires two tokens:
|
|
- SLACK_BOT_TOKEN (xoxb-...) for API calls
|
|
- SLACK_APP_TOKEN (xapp-...) for Socket Mode connection
|
|
|
|
Features:
|
|
- DMs and channel messages (mention-gated in channels)
|
|
- Thread support
|
|
- File/image/audio attachments
|
|
- Slash commands (/hermes)
|
|
- Typing indicators (not natively supported by Slack bots)
|
|
"""
|
|
|
|
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
|
|
|
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."""
|
|
if not SLACK_AVAILABLE:
|
|
logger.error(
|
|
"[Slack] slack-bolt not installed. Run: pip install slack-bolt",
|
|
)
|
|
return False
|
|
|
|
bot_token = self.config.token
|
|
app_token = os.getenv("SLACK_APP_TOKEN")
|
|
|
|
if not bot_token:
|
|
logger.error("[Slack] SLACK_BOT_TOKEN not set")
|
|
return False
|
|
if not app_token:
|
|
logger.error("[Slack] SLACK_APP_TOKEN not set")
|
|
return False
|
|
|
|
try:
|
|
self._app = AsyncApp(token=bot_token)
|
|
|
|
# Get our own bot user ID for mention detection
|
|
auth_response = await self._app.client.auth_test()
|
|
self._bot_user_id = auth_response.get("user_id")
|
|
bot_name = auth_response.get("user", "unknown")
|
|
|
|
# Register message event handler
|
|
@self._app.event("message")
|
|
async def handle_message_event(event, say):
|
|
await self._handle_slack_message(event)
|
|
|
|
# Acknowledge app_mention events to prevent Bolt 404 errors.
|
|
# The "message" handler above already processes @mentions in
|
|
# channels, so this is intentionally a no-op to avoid duplicates.
|
|
@self._app.event("app_mention")
|
|
async def handle_app_mention(event, say):
|
|
pass
|
|
|
|
# Register slash command handler
|
|
@self._app.command("/hermes")
|
|
async def handle_hermes_command(ack, command):
|
|
await ack()
|
|
await self._handle_slash_command(command)
|
|
|
|
# Start Socket Mode handler in background
|
|
self._handler = AsyncSocketModeHandler(self._app, app_token)
|
|
asyncio.create_task(self._handler.start_async())
|
|
|
|
self._running = True
|
|
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
|
|
return True
|
|
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error("[Slack] Connection failed: %s", e, exc_info=True)
|
|
return False
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from Slack."""
|
|
if self._handler:
|
|
try:
|
|
await self._handler.close_async()
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True)
|
|
self._running = False
|
|
logger.info("[Slack] Disconnected")
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a message to a Slack channel or DM."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
# 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)
|
|
|
|
thread_ts = self._resolve_thread_ts(reply_to, metadata)
|
|
last_result = None
|
|
|
|
# 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=last_result.get("ts") if last_result else None,
|
|
raw_response=last_result,
|
|
)
|
|
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error("[Slack] Send error: %s", e, exc_info=True)
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
async def edit_message(
|
|
self,
|
|
chat_id: str,
|
|
message_id: str,
|
|
content: str,
|
|
) -> SendResult:
|
|
"""Edit a previously sent Slack message."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
await self._app.client.chat_update(
|
|
channel=chat_id,
|
|
ts=message_id,
|
|
text=content,
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[Slack] Failed to edit message %s in channel %s: %s",
|
|
message_id,
|
|
chat_id,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
"""Show a typing/status indicator using assistant.threads.setStatus.
|
|
|
|
Displays "is thinking..." next to the bot name in a thread.
|
|
Requires the assistant:write or chat:write scope.
|
|
Auto-clears when the bot sends a reply to the thread.
|
|
"""
|
|
if not self._app:
|
|
return
|
|
|
|
thread_ts = None
|
|
if metadata:
|
|
thread_ts = metadata.get("thread_id") or metadata.get("thread_ts")
|
|
|
|
if not thread_ts:
|
|
return # Can only set status in a thread context
|
|
|
|
try:
|
|
await self._app.client.assistant_threads_setStatus(
|
|
channel_id=chat_id,
|
|
thread_ts=thread_ts,
|
|
status="is thinking...",
|
|
)
|
|
except Exception as e:
|
|
# Silently ignore — may lack assistant:write scope or not be
|
|
# in an assistant-enabled context. Falls back to reactions.
|
|
logger.debug("[Slack] assistant.threads.setStatus failed: %s", e)
|
|
|
|
def _resolve_thread_ts(
|
|
self,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[str]:
|
|
"""Resolve the correct thread_ts for a Slack API call.
|
|
|
|
Prefers metadata thread_id (the thread parent's ts, set by the
|
|
gateway) over reply_to (which may be a child message's ts).
|
|
"""
|
|
if metadata:
|
|
if metadata.get("thread_id"):
|
|
return metadata["thread_id"]
|
|
if metadata.get("thread_ts"):
|
|
return metadata["thread_ts"]
|
|
return reply_to
|
|
|
|
async def _upload_file(
|
|
self,
|
|
chat_id: str,
|
|
file_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Upload a local file to Slack."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
if not os.path.exists(file_path):
|
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
|
|
result = await self._app.client.files_upload_v2(
|
|
channel=chat_id,
|
|
file=file_path,
|
|
filename=os.path.basename(file_path),
|
|
initial_comment=caption or "",
|
|
thread_ts=self._resolve_thread_ts(reply_to, metadata),
|
|
)
|
|
return SendResult(success=True, raw_response=result)
|
|
|
|
# ----- 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,
|
|
image_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a local image file to Slack by uploading it."""
|
|
try:
|
|
return await self._upload_file(chat_id, image_path, caption, reply_to, metadata)
|
|
except FileNotFoundError:
|
|
return SendResult(success=False, error=f"Image file not found: {image_path}")
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[%s] Failed to send local Slack image %s: %s",
|
|
self.name,
|
|
image_path,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
text = f"🖼️ Image: {image_path}"
|
|
if caption:
|
|
text = f"{caption}\n{text}"
|
|
return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
|
|
|
|
async def send_image(
|
|
self,
|
|
chat_id: str,
|
|
image_url: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send an image to Slack by uploading the URL as a file."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
import httpx
|
|
|
|
# Download the image first
|
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
response = await client.get(image_url)
|
|
response.raise_for_status()
|
|
|
|
result = await self._app.client.files_upload_v2(
|
|
channel=chat_id,
|
|
content=response.content,
|
|
filename="image.png",
|
|
initial_comment=caption or "",
|
|
thread_ts=self._resolve_thread_ts(reply_to, metadata),
|
|
)
|
|
|
|
return SendResult(success=True, raw_response=result)
|
|
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.warning(
|
|
"[Slack] Failed to upload image from URL %s, falling back to text: %s",
|
|
image_url,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
# Fall back to sending the URL as text
|
|
text = f"{caption}\n{image_url}" if caption else image_url
|
|
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)
|
|
|
|
async def send_voice(
|
|
self,
|
|
chat_id: str,
|
|
audio_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
**kwargs,
|
|
) -> SendResult:
|
|
"""Send an audio file to Slack."""
|
|
try:
|
|
return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata)
|
|
except FileNotFoundError:
|
|
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[Slack] Failed to send audio file %s: %s",
|
|
audio_path,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
async def send_video(
|
|
self,
|
|
chat_id: str,
|
|
video_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a video file to Slack."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
if not os.path.exists(video_path):
|
|
return SendResult(success=False, error=f"Video file not found: {video_path}")
|
|
|
|
try:
|
|
result = await self._app.client.files_upload_v2(
|
|
channel=chat_id,
|
|
file=video_path,
|
|
filename=os.path.basename(video_path),
|
|
initial_comment=caption or "",
|
|
thread_ts=self._resolve_thread_ts(reply_to, metadata),
|
|
)
|
|
return SendResult(success=True, raw_response=result)
|
|
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[%s] Failed to send video %s: %s",
|
|
self.name,
|
|
video_path,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
text = f"🎬 Video: {video_path}"
|
|
if caption:
|
|
text = f"{caption}\n{text}"
|
|
return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
|
|
|
|
async def send_document(
|
|
self,
|
|
chat_id: str,
|
|
file_path: str,
|
|
caption: Optional[str] = None,
|
|
file_name: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a document/file attachment to Slack."""
|
|
if not self._app:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
if not os.path.exists(file_path):
|
|
return SendResult(success=False, error=f"File not found: {file_path}")
|
|
|
|
display_name = file_name or os.path.basename(file_path)
|
|
|
|
try:
|
|
result = await self._app.client.files_upload_v2(
|
|
channel=chat_id,
|
|
file=file_path,
|
|
filename=display_name,
|
|
initial_comment=caption or "",
|
|
thread_ts=self._resolve_thread_ts(reply_to, metadata),
|
|
)
|
|
return SendResult(success=True, raw_response=result)
|
|
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[%s] Failed to send document %s: %s",
|
|
self.name,
|
|
file_path,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
text = f"📎 File: {file_path}"
|
|
if caption:
|
|
text = f"{caption}\n{text}"
|
|
return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Get information about a Slack channel."""
|
|
if not self._app:
|
|
return {"name": chat_id, "type": "unknown"}
|
|
|
|
try:
|
|
result = await self._app.client.conversations_info(channel=chat_id)
|
|
channel = result.get("channel", {})
|
|
is_dm = channel.get("is_im", False)
|
|
return {
|
|
"name": channel.get("name", chat_id),
|
|
"type": "dm" if is_dm else "group",
|
|
}
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.error(
|
|
"[Slack] Failed to fetch chat info for %s: %s",
|
|
chat_id,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return {"name": chat_id, "type": "unknown"}
|
|
|
|
# ----- Internal handlers -----
|
|
|
|
async def _handle_slack_message(self, event: dict) -> None:
|
|
"""Handle an incoming Slack message event."""
|
|
# Ignore bot messages (including our own)
|
|
if event.get("bot_id") or event.get("subtype") == "bot_message":
|
|
return
|
|
|
|
# Ignore message edits and deletions
|
|
subtype = event.get("subtype")
|
|
if subtype in ("message_changed", "message_deleted"):
|
|
return
|
|
|
|
text = event.get("text", "")
|
|
user_id = event.get("user", "")
|
|
channel_id = event.get("channel", "")
|
|
ts = event.get("ts", "")
|
|
|
|
# Determine if this is a DM or channel message
|
|
channel_type = event.get("channel_type", "")
|
|
is_dm = channel_type == "im"
|
|
|
|
# Build thread_ts for session keying.
|
|
# In channels: fall back to ts so each top-level @mention starts a
|
|
# new thread/session (the bot always replies in a thread).
|
|
# In DMs: only use the real thread_ts — top-level DMs should share
|
|
# one continuous session, threaded DMs get their own session.
|
|
if is_dm:
|
|
thread_ts = event.get("thread_ts") # None for top-level DMs
|
|
else:
|
|
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
|
|
|
# In channels, only respond if bot is mentioned
|
|
if not is_dm and self._bot_user_id:
|
|
if f"<@{self._bot_user_id}>" not in text:
|
|
return
|
|
# Strip the bot mention from the text
|
|
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
|
|
|
|
# Determine message type
|
|
msg_type = MessageType.TEXT
|
|
if text.startswith("/"):
|
|
msg_type = MessageType.COMMAND
|
|
|
|
# Handle file attachments
|
|
media_urls = []
|
|
media_types = []
|
|
files = event.get("files", [])
|
|
for f in files:
|
|
mimetype = f.get("mimetype", "unknown")
|
|
url = f.get("url_private_download") or f.get("url_private", "")
|
|
if mimetype.startswith("image/") and url:
|
|
try:
|
|
ext = "." + mimetype.split("/")[-1].split(";")[0]
|
|
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
|
ext = ".jpg"
|
|
# Slack private URLs require the bot token as auth header
|
|
cached = await self._download_slack_file(url, ext)
|
|
media_urls.append(cached)
|
|
media_types.append(mimetype)
|
|
msg_type = MessageType.PHOTO
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
|
|
elif mimetype.startswith("audio/") and url:
|
|
try:
|
|
ext = "." + mimetype.split("/")[-1].split(";")[0]
|
|
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
|
|
ext = ".ogg"
|
|
cached = await self._download_slack_file(url, ext, audio=True)
|
|
media_urls.append(cached)
|
|
media_types.append(mimetype)
|
|
msg_type = MessageType.VOICE
|
|
except Exception as e: # pragma: no cover - defensive logging
|
|
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
|
|
elif url:
|
|
# Try to handle as a document attachment
|
|
try:
|
|
original_filename = f.get("name", "")
|
|
ext = ""
|
|
if original_filename:
|
|
_, ext = os.path.splitext(original_filename)
|
|
ext = ext.lower()
|
|
|
|
# Fallback: reverse-lookup from MIME type
|
|
if not ext and mimetype:
|
|
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
|
ext = mime_to_ext.get(mimetype, "")
|
|
|
|
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
|
continue # Skip unsupported file types silently
|
|
|
|
# Check file size (Slack limit: 20 MB for bots)
|
|
file_size = f.get("size", 0)
|
|
MAX_DOC_BYTES = 20 * 1024 * 1024
|
|
if not file_size or file_size > MAX_DOC_BYTES:
|
|
logger.warning("[Slack] Document too large or unknown size: %s", file_size)
|
|
continue
|
|
|
|
# Download and cache
|
|
raw_bytes = await self._download_slack_file_bytes(url)
|
|
cached_path = cache_document_from_bytes(
|
|
raw_bytes, original_filename or f"document{ext}"
|
|
)
|
|
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
|
|
media_urls.append(cached_path)
|
|
media_types.append(doc_mime)
|
|
msg_type = MessageType.DOCUMENT
|
|
logger.debug("[Slack] Cached user document: %s", cached_path)
|
|
|
|
# Inject text content for .txt/.md files (capped at 100 KB)
|
|
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
|
if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
|
|
try:
|
|
text_content = raw_bytes.decode("utf-8")
|
|
display_name = original_filename or f"document{ext}"
|
|
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
|
|
injection = f"[Content of {display_name}]:\n{text_content}"
|
|
if text:
|
|
text = f"{injection}\n\n{text}"
|
|
else:
|
|
text = injection
|
|
except UnicodeDecodeError:
|
|
pass # Binary content, skip injection
|
|
|
|
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,
|
|
)
|
|
|
|
msg_event = MessageEvent(
|
|
text=text,
|
|
message_type=msg_type,
|
|
source=source,
|
|
raw_message=event,
|
|
message_id=ts,
|
|
media_urls=media_urls,
|
|
media_types=media_types,
|
|
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()
|
|
user_id = command.get("user_id", "")
|
|
channel_id = command.get("channel_id", "")
|
|
|
|
# Map subcommands to gateway commands
|
|
subcommand_map = {
|
|
"new": "/reset", "reset": "/reset",
|
|
"status": "/status", "stop": "/stop",
|
|
"help": "/help",
|
|
"model": "/model", "personality": "/personality",
|
|
"retry": "/retry", "undo": "/undo",
|
|
"compact": "/compress", "compress": "/compress",
|
|
"resume": "/resume",
|
|
"background": "/background",
|
|
"bg": "/bg",
|
|
"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:
|
|
# Preserve arguments after the subcommand
|
|
rest = text[len(first_word):].strip()
|
|
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
|
|
elif text:
|
|
pass # Treat as a regular question
|
|
else:
|
|
text = "/help"
|
|
|
|
source = self.build_source(
|
|
chat_id=channel_id,
|
|
chat_type="dm", # Slash commands are always in DM-like context
|
|
user_id=user_id,
|
|
)
|
|
|
|
event = MessageEvent(
|
|
text=text,
|
|
message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT,
|
|
source=source,
|
|
raw_message=command,
|
|
)
|
|
|
|
await self.handle_message(event)
|
|
|
|
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
|
|
"""Download a Slack file using the bot token for auth."""
|
|
import httpx
|
|
|
|
bot_token = self.config.token
|
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
response = await client.get(
|
|
url,
|
|
headers={"Authorization": f"Bearer {bot_token}"},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
if audio:
|
|
from gateway.platforms.base import cache_audio_from_bytes
|
|
return cache_audio_from_bytes(response.content, ext)
|
|
else:
|
|
from gateway.platforms.base import cache_image_from_bytes
|
|
return cache_image_from_bytes(response.content, ext)
|
|
|
|
async def _download_slack_file_bytes(self, url: str) -> bytes:
|
|
"""Download a Slack file and return raw bytes."""
|
|
import httpx
|
|
|
|
bot_token = self.config.token
|
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
response = await client.get(
|
|
url,
|
|
headers={"Authorization": f"Bearer {bot_token}"},
|
|
)
|
|
response.raise_for_status()
|
|
return response.content
|