Add configurable bot message filtering via DISCORD_ALLOW_BOTS env var: - 'none' (default): Ignore all other bot messages — matches previous behavior where only our own bot was filtered, but now ALL bots are filtered by default for cleaner channels - 'mentions': Accept bot messages only when they @mention our bot — useful for bot-to-bot workflows triggered by mentions - 'all': Accept all bot messages — for setups where bots need to interact freely Previously, we only ignored our own bot's messages, allowing all other bots through. This could cause noisy loops in channels with multiple bots. 8 new tests covering all filter modes and edge cases. Inspired by openclaw v2026.3.7 Discord allowBots: 'mentions' config.
991 lines
42 KiB
Python
991 lines
42 KiB
Python
"""
|
|
Discord platform adapter.
|
|
|
|
Uses discord.py library for:
|
|
- Receiving messages from servers and DMs
|
|
- Sending responses back
|
|
- Handling threads and channels
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import discord
|
|
from discord import Message as DiscordMessage, Intents
|
|
from discord.ext import commands
|
|
DISCORD_AVAILABLE = True
|
|
except ImportError:
|
|
DISCORD_AVAILABLE = False
|
|
discord = None
|
|
DiscordMessage = Any
|
|
Intents = Any
|
|
commands = None
|
|
|
|
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,
|
|
cache_image_from_url,
|
|
cache_audio_from_url,
|
|
)
|
|
|
|
|
|
def check_discord_requirements() -> bool:
|
|
"""Check if Discord dependencies are available."""
|
|
return DISCORD_AVAILABLE
|
|
|
|
|
|
class DiscordAdapter(BasePlatformAdapter):
|
|
"""
|
|
Discord bot adapter.
|
|
|
|
Handles:
|
|
- Receiving messages from servers and DMs
|
|
- Sending responses with Discord markdown
|
|
- Thread support
|
|
- Native slash commands (/ask, /reset, /status, /stop)
|
|
- Button-based exec approvals
|
|
- Auto-threading for long conversations
|
|
- Reaction-based feedback
|
|
"""
|
|
|
|
# Discord message limits
|
|
MAX_MESSAGE_LENGTH = 2000
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
super().__init__(config, Platform.DISCORD)
|
|
self._client: Optional[commands.Bot] = None
|
|
self._ready_event = asyncio.Event()
|
|
self._allowed_user_ids: set = set() # For button approval authorization
|
|
|
|
async def connect(self) -> bool:
|
|
"""Connect to Discord and start receiving events."""
|
|
if not DISCORD_AVAILABLE:
|
|
print(f"[{self.name}] discord.py not installed. Run: pip install discord.py")
|
|
return False
|
|
|
|
if not self.config.token:
|
|
print(f"[{self.name}] No bot token configured")
|
|
return False
|
|
|
|
try:
|
|
# Set up intents -- members intent needed for username-to-ID resolution
|
|
intents = Intents.default()
|
|
intents.message_content = True
|
|
intents.dm_messages = True
|
|
intents.guild_messages = True
|
|
intents.members = True
|
|
|
|
# Create bot
|
|
self._client = commands.Bot(
|
|
command_prefix="!", # Not really used, we handle raw messages
|
|
intents=intents,
|
|
)
|
|
|
|
# Parse allowed user entries (may contain usernames or IDs)
|
|
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
|
|
if allowed_env:
|
|
self._allowed_user_ids = {
|
|
uid.strip() for uid in allowed_env.split(",") if uid.strip()
|
|
}
|
|
|
|
adapter_self = self # capture for closure
|
|
|
|
# Register event handlers
|
|
@self._client.event
|
|
async def on_ready():
|
|
print(f"[{adapter_self.name}] Connected as {adapter_self._client.user}")
|
|
|
|
# Resolve any usernames in the allowed list to numeric IDs
|
|
await adapter_self._resolve_allowed_usernames()
|
|
|
|
# Sync slash commands with Discord
|
|
try:
|
|
synced = await adapter_self._client.tree.sync()
|
|
print(f"[{adapter_self.name}] Synced {len(synced)} slash command(s)")
|
|
except Exception as e:
|
|
print(f"[{adapter_self.name}] Slash command sync failed: {e}")
|
|
adapter_self._ready_event.set()
|
|
|
|
@self._client.event
|
|
async def on_message(message: DiscordMessage):
|
|
# Always ignore our own messages
|
|
if message.author == self._client.user:
|
|
return
|
|
|
|
# Bot message filtering (DISCORD_ALLOW_BOTS):
|
|
# "none" — ignore all other bots (default)
|
|
# "mentions" — accept bot messages only when they @mention us
|
|
# "all" — accept all bot messages
|
|
if getattr(message.author, "bot", False):
|
|
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
|
|
if allow_bots == "none":
|
|
return
|
|
elif allow_bots == "mentions":
|
|
if not self._client.user or self._client.user not in message.mentions:
|
|
return
|
|
# "all" falls through to handle_message
|
|
|
|
await self._handle_message(message)
|
|
|
|
# Register slash commands
|
|
self._register_slash_commands()
|
|
|
|
# Start the bot in background
|
|
asyncio.create_task(self._client.start(self.config.token))
|
|
|
|
# Wait for ready
|
|
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
|
|
|
|
self._running = True
|
|
return True
|
|
|
|
except asyncio.TimeoutError:
|
|
print(f"[{self.name}] Timeout waiting for connection")
|
|
return False
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to connect: {e}")
|
|
return False
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from Discord."""
|
|
if self._client:
|
|
try:
|
|
await self._client.close()
|
|
except Exception as e:
|
|
print(f"[{self.name}] Error during disconnect: {e}")
|
|
|
|
self._running = False
|
|
self._client = None
|
|
self._ready_event.clear()
|
|
print(f"[{self.name}] 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 Discord channel."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
# Get the channel
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
|
|
if not channel:
|
|
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
|
|
|
# Format and split message if needed
|
|
formatted = self.format_message(content)
|
|
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
|
|
|
message_ids = []
|
|
reference = None
|
|
|
|
if reply_to:
|
|
try:
|
|
ref_msg = await channel.fetch_message(int(reply_to))
|
|
reference = ref_msg
|
|
except Exception as e:
|
|
logger.debug("Could not fetch reply-to message: %s", e)
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
msg = await channel.send(
|
|
content=chunk,
|
|
reference=reference if i == 0 else None,
|
|
)
|
|
message_ids.append(str(msg.id))
|
|
|
|
return SendResult(
|
|
success=True,
|
|
message_id=message_ids[0] if message_ids else None,
|
|
raw_response={"message_ids": message_ids}
|
|
)
|
|
|
|
except Exception as e:
|
|
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 Discord message."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
msg = await channel.fetch_message(int(message_id))
|
|
formatted = self.format_message(content)
|
|
if len(formatted) > self.MAX_MESSAGE_LENGTH:
|
|
formatted = formatted[:self.MAX_MESSAGE_LENGTH - 3] + "..."
|
|
await msg.edit(content=formatted)
|
|
return SendResult(success=True, message_id=message_id)
|
|
except Exception as e:
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
async def send_voice(
|
|
self,
|
|
chat_id: str,
|
|
audio_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
) -> SendResult:
|
|
"""Send audio as a Discord file attachment."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
import io
|
|
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
if not channel:
|
|
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
|
|
|
if not os.path.exists(audio_path):
|
|
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
|
|
|
|
# Determine filename from path
|
|
filename = os.path.basename(audio_path)
|
|
|
|
with open(audio_path, "rb") as f:
|
|
file = discord.File(io.BytesIO(f.read()), filename=filename)
|
|
msg = await channel.send(
|
|
content=caption if caption else None,
|
|
file=file,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.id))
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to send audio: {e}")
|
|
return await super().send_voice(chat_id, audio_path, caption, reply_to)
|
|
|
|
async def send_image_file(
|
|
self,
|
|
chat_id: str,
|
|
image_path: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
) -> SendResult:
|
|
"""Send a local image file natively as a Discord file attachment."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
import io
|
|
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
if not channel:
|
|
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
|
|
|
if not os.path.exists(image_path):
|
|
return SendResult(success=False, error=f"Image file not found: {image_path}")
|
|
|
|
filename = os.path.basename(image_path)
|
|
|
|
with open(image_path, "rb") as f:
|
|
file = discord.File(io.BytesIO(f.read()), filename=filename)
|
|
msg = await channel.send(
|
|
content=caption if caption else None,
|
|
file=file,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.id))
|
|
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to send local image: {e}")
|
|
return await super().send_image_file(chat_id, image_path, caption, reply_to)
|
|
|
|
async def send_image(
|
|
self,
|
|
chat_id: str,
|
|
image_url: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
) -> SendResult:
|
|
"""Send an image natively as a Discord file attachment."""
|
|
if not self._client:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
import aiohttp
|
|
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
if not channel:
|
|
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
|
|
|
# Download the image and send as a Discord file attachment
|
|
# (Discord renders attachments inline, unlike plain URLs)
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
if resp.status != 200:
|
|
raise Exception(f"Failed to download image: HTTP {resp.status}")
|
|
|
|
image_data = await resp.read()
|
|
|
|
# Determine filename from URL or content type
|
|
content_type = resp.headers.get("content-type", "image/png")
|
|
ext = "png"
|
|
if "jpeg" in content_type or "jpg" in content_type:
|
|
ext = "jpg"
|
|
elif "gif" in content_type:
|
|
ext = "gif"
|
|
elif "webp" in content_type:
|
|
ext = "webp"
|
|
|
|
import io
|
|
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
|
|
|
|
msg = await channel.send(
|
|
content=caption if caption else None,
|
|
file=file,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.id))
|
|
|
|
except ImportError:
|
|
print(f"[{self.name}] aiohttp not installed, falling back to URL. Run: pip install aiohttp")
|
|
return await super().send_image(chat_id, image_url, caption, reply_to)
|
|
except Exception as e:
|
|
print(f"[{self.name}] Failed to send image attachment, falling back to URL: {e}")
|
|
return await super().send_image(chat_id, image_url, caption, reply_to)
|
|
|
|
async def send_typing(self, chat_id: str) -> None:
|
|
"""Send typing indicator."""
|
|
if self._client:
|
|
try:
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if channel:
|
|
await channel.typing()
|
|
except Exception:
|
|
pass # Ignore typing indicator failures
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Get information about a Discord channel."""
|
|
if not self._client:
|
|
return {"name": "Unknown", "type": "dm"}
|
|
|
|
try:
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
|
|
if not channel:
|
|
return {"name": str(chat_id), "type": "dm"}
|
|
|
|
# Determine channel type
|
|
if isinstance(channel, discord.DMChannel):
|
|
chat_type = "dm"
|
|
name = channel.recipient.name if channel.recipient else str(chat_id)
|
|
elif isinstance(channel, discord.Thread):
|
|
chat_type = "thread"
|
|
name = channel.name
|
|
elif isinstance(channel, discord.TextChannel):
|
|
chat_type = "channel"
|
|
name = f"#{channel.name}"
|
|
if channel.guild:
|
|
name = f"{channel.guild.name} / {name}"
|
|
else:
|
|
chat_type = "channel"
|
|
name = getattr(channel, "name", str(chat_id))
|
|
|
|
return {
|
|
"name": name,
|
|
"type": chat_type,
|
|
"guild_id": str(channel.guild.id) if hasattr(channel, "guild") and channel.guild else None,
|
|
"guild_name": channel.guild.name if hasattr(channel, "guild") and channel.guild else None,
|
|
}
|
|
except Exception as e:
|
|
return {"name": str(chat_id), "type": "dm", "error": str(e)}
|
|
|
|
async def _resolve_allowed_usernames(self) -> None:
|
|
"""
|
|
Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs.
|
|
|
|
Users can specify usernames (e.g. "teknium") or display names instead of
|
|
raw numeric IDs. After resolution, the env var and internal set are updated
|
|
so authorization checks work with IDs only.
|
|
"""
|
|
if not self._allowed_user_ids or not self._client:
|
|
return
|
|
|
|
numeric_ids = set()
|
|
to_resolve = set()
|
|
|
|
for entry in self._allowed_user_ids:
|
|
if entry.isdigit():
|
|
numeric_ids.add(entry)
|
|
else:
|
|
to_resolve.add(entry.lower())
|
|
|
|
if not to_resolve:
|
|
return
|
|
|
|
print(f"[{self.name}] Resolving {len(to_resolve)} username(s): {', '.join(to_resolve)}")
|
|
resolved_count = 0
|
|
|
|
for guild in self._client.guilds:
|
|
# Fetch full member list (requires members intent)
|
|
try:
|
|
members = guild.members
|
|
if len(members) < guild.member_count:
|
|
members = [m async for m in guild.fetch_members(limit=None)]
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch members for guild %s: %s", guild.name, e)
|
|
continue
|
|
|
|
for member in members:
|
|
name_lower = member.name.lower()
|
|
display_lower = member.display_name.lower()
|
|
global_lower = (member.global_name or "").lower()
|
|
|
|
matched = name_lower in to_resolve or display_lower in to_resolve or global_lower in to_resolve
|
|
if matched:
|
|
uid = str(member.id)
|
|
numeric_ids.add(uid)
|
|
resolved_count += 1
|
|
matched_name = name_lower if name_lower in to_resolve else (
|
|
display_lower if display_lower in to_resolve else global_lower
|
|
)
|
|
to_resolve.discard(matched_name)
|
|
print(f"[{self.name}] Resolved '{matched_name}' -> {uid} ({member.name}#{member.discriminator})")
|
|
|
|
if not to_resolve:
|
|
break
|
|
|
|
if to_resolve:
|
|
print(f"[{self.name}] Could not resolve usernames: {', '.join(to_resolve)}")
|
|
|
|
# Update internal set and env var so gateway auth checks use IDs
|
|
self._allowed_user_ids = numeric_ids
|
|
os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids))
|
|
if resolved_count:
|
|
print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)")
|
|
|
|
def format_message(self, content: str) -> str:
|
|
"""
|
|
Format message for Discord.
|
|
|
|
Discord uses its own markdown variant.
|
|
"""
|
|
# Discord markdown is fairly standard, no special escaping needed
|
|
return content
|
|
|
|
def _register_slash_commands(self) -> None:
|
|
"""Register Discord slash commands on the command tree."""
|
|
if not self._client:
|
|
return
|
|
|
|
tree = self._client.tree
|
|
|
|
@tree.command(name="ask", description="Ask Hermes a question")
|
|
@discord.app_commands.describe(question="Your question for Hermes")
|
|
async def slash_ask(interaction: discord.Interaction, question: str):
|
|
await interaction.response.defer()
|
|
event = self._build_slash_event(interaction, question)
|
|
await self.handle_message(event)
|
|
# The response is sent via the normal send() flow
|
|
# Send a followup to close the interaction if needed
|
|
try:
|
|
await interaction.followup.send("Processing complete~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="new", description="Start a new conversation")
|
|
async def slash_new(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/reset")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("New conversation started~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="reset", description="Reset your Hermes session")
|
|
async def slash_reset(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/reset")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Session reset~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="model", description="Show or change the model")
|
|
@discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.")
|
|
async def slash_model(interaction: discord.Interaction, name: str = ""):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, f"/model {name}".strip())
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="personality", description="Set a personality")
|
|
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
|
async def slash_personality(interaction: discord.Interaction, name: str = ""):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, f"/personality {name}".strip())
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="retry", description="Retry your last message")
|
|
async def slash_retry(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/retry")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Retrying~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="undo", description="Remove the last exchange")
|
|
async def slash_undo(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/undo")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="status", description="Show Hermes session status")
|
|
async def slash_status(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/status")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Status sent~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="sethome", description="Set this chat as the home channel")
|
|
async def slash_sethome(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/sethome")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="stop", description="Stop the running Hermes agent")
|
|
async def slash_stop(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/stop")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Stop requested~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="compress", description="Compress conversation context")
|
|
async def slash_compress(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/compress")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="title", description="Set or show the session title")
|
|
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
|
|
async def slash_title(interaction: discord.Interaction, name: str = ""):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, f"/title {name}".strip())
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="resume", description="Resume a previously-named session")
|
|
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
|
|
async def slash_resume(interaction: discord.Interaction, name: str = ""):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, f"/resume {name}".strip())
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="usage", description="Show token usage for this session")
|
|
async def slash_usage(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/usage")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="provider", description="Show available providers")
|
|
async def slash_provider(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/provider")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="help", description="Show available commands")
|
|
async def slash_help(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/help")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="insights", description="Show usage insights and analytics")
|
|
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
|
|
async def slash_insights(interaction: discord.Interaction, days: int = 7):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, f"/insights {days}")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
|
|
async def slash_reload_mcp(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/reload-mcp")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Done~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
|
async def slash_update(interaction: discord.Interaction):
|
|
await interaction.response.defer(ephemeral=True)
|
|
event = self._build_slash_event(interaction, "/update")
|
|
await self.handle_message(event)
|
|
try:
|
|
await interaction.followup.send("Update initiated~", ephemeral=True)
|
|
except Exception as e:
|
|
logger.debug("Discord followup failed: %s", e)
|
|
|
|
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
|
"""Build a MessageEvent from a Discord slash command interaction."""
|
|
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
|
chat_type = "dm" if is_dm else "group"
|
|
chat_name = ""
|
|
if not is_dm and hasattr(interaction.channel, "name"):
|
|
chat_name = interaction.channel.name
|
|
if hasattr(interaction.channel, "guild") and interaction.channel.guild:
|
|
chat_name = f"{interaction.channel.guild.name} / #{chat_name}"
|
|
|
|
# Get channel topic (if available)
|
|
chat_topic = getattr(interaction.channel, "topic", None)
|
|
|
|
source = self.build_source(
|
|
chat_id=str(interaction.channel_id),
|
|
chat_name=chat_name,
|
|
chat_type=chat_type,
|
|
user_id=str(interaction.user.id),
|
|
user_name=interaction.user.display_name,
|
|
chat_topic=chat_topic,
|
|
)
|
|
|
|
msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT
|
|
return MessageEvent(
|
|
text=text,
|
|
message_type=msg_type,
|
|
source=source,
|
|
raw_message=interaction,
|
|
)
|
|
|
|
async def send_exec_approval(
|
|
self, chat_id: str, command: str, approval_id: str
|
|
) -> SendResult:
|
|
"""
|
|
Send a button-based exec approval prompt for a dangerous command.
|
|
|
|
Returns SendResult. The approval is resolved when a user clicks a button.
|
|
"""
|
|
if not self._client or not DISCORD_AVAILABLE:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
channel = self._client.get_channel(int(chat_id))
|
|
if not channel:
|
|
channel = await self._client.fetch_channel(int(chat_id))
|
|
|
|
embed = discord.Embed(
|
|
title="Command Approval Required",
|
|
description=f"```\n{command[:500]}\n```",
|
|
color=discord.Color.orange(),
|
|
)
|
|
embed.set_footer(text=f"Approval ID: {approval_id}")
|
|
|
|
view = ExecApprovalView(
|
|
approval_id=approval_id,
|
|
allowed_user_ids=self._allowed_user_ids,
|
|
)
|
|
|
|
msg = await channel.send(embed=embed, view=view)
|
|
return SendResult(success=True, message_id=str(msg.id))
|
|
|
|
except Exception as e:
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
async def _handle_message(self, message: DiscordMessage) -> None:
|
|
"""Handle incoming Discord messages."""
|
|
# In server channels (not DMs), require the bot to be @mentioned
|
|
# UNLESS the channel is in the free-response list.
|
|
#
|
|
# Config:
|
|
# DISCORD_FREE_RESPONSE_CHANNELS: Comma-separated channel IDs where the
|
|
# bot responds to every message without needing a mention.
|
|
# DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement
|
|
# globally (all channels become free-response). Default: "true".
|
|
|
|
if not isinstance(message.channel, discord.DMChannel):
|
|
# Check if this channel is in the free-response list
|
|
free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
|
|
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
|
channel_id = str(message.channel.id)
|
|
|
|
# Global override: if DISCORD_REQUIRE_MENTION=false, all channels are free
|
|
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
|
|
|
|
is_free_channel = channel_id in free_channels
|
|
|
|
if require_mention and not is_free_channel:
|
|
# Must be @mentioned to respond
|
|
if self._client.user not in message.mentions:
|
|
return # Silently ignore messages that don't mention the bot
|
|
|
|
# Strip the bot mention from the message text so the agent sees clean input
|
|
if self._client.user and self._client.user in message.mentions:
|
|
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
|
|
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
|
|
|
# Determine message type
|
|
msg_type = MessageType.TEXT
|
|
if message.content.startswith("/"):
|
|
msg_type = MessageType.COMMAND
|
|
elif message.attachments:
|
|
# Check attachment types
|
|
for att in message.attachments:
|
|
if att.content_type:
|
|
if att.content_type.startswith("image/"):
|
|
msg_type = MessageType.PHOTO
|
|
elif att.content_type.startswith("video/"):
|
|
msg_type = MessageType.VIDEO
|
|
elif att.content_type.startswith("audio/"):
|
|
msg_type = MessageType.AUDIO
|
|
else:
|
|
msg_type = MessageType.DOCUMENT
|
|
break
|
|
|
|
# Determine chat type
|
|
if isinstance(message.channel, discord.DMChannel):
|
|
chat_type = "dm"
|
|
chat_name = message.author.name
|
|
elif isinstance(message.channel, discord.Thread):
|
|
chat_type = "thread"
|
|
chat_name = message.channel.name
|
|
else:
|
|
chat_type = "group" # Treat server channels as groups
|
|
chat_name = getattr(message.channel, "name", str(message.channel.id))
|
|
if hasattr(message.channel, "guild") and message.channel.guild:
|
|
chat_name = f"{message.channel.guild.name} / #{chat_name}"
|
|
|
|
# Get thread ID if in a thread
|
|
thread_id = None
|
|
if isinstance(message.channel, discord.Thread):
|
|
thread_id = str(message.channel.id)
|
|
|
|
# Get channel topic (if available - TextChannels have topics, DMs/threads don't)
|
|
chat_topic = getattr(message.channel, "topic", None)
|
|
|
|
# Build source
|
|
source = self.build_source(
|
|
chat_id=str(message.channel.id),
|
|
chat_name=chat_name,
|
|
chat_type=chat_type,
|
|
user_id=str(message.author.id),
|
|
user_name=message.author.display_name,
|
|
thread_id=thread_id,
|
|
chat_topic=chat_topic,
|
|
)
|
|
|
|
# Build media URLs -- download image attachments to local cache so the
|
|
# vision tool can access them reliably (Discord CDN URLs can expire).
|
|
media_urls = []
|
|
media_types = []
|
|
for att in message.attachments:
|
|
content_type = att.content_type or "unknown"
|
|
if content_type.startswith("image/"):
|
|
try:
|
|
# Determine extension from content type (image/png -> .png)
|
|
ext = "." + content_type.split("/")[-1].split(";")[0]
|
|
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
|
ext = ".jpg"
|
|
cached_path = await cache_image_from_url(att.url, ext=ext)
|
|
media_urls.append(cached_path)
|
|
media_types.append(content_type)
|
|
print(f"[Discord] Cached user image: {cached_path}", flush=True)
|
|
except Exception as e:
|
|
print(f"[Discord] Failed to cache image attachment: {e}", flush=True)
|
|
# Fall back to the CDN URL if caching fails
|
|
media_urls.append(att.url)
|
|
media_types.append(content_type)
|
|
elif content_type.startswith("audio/"):
|
|
try:
|
|
ext = "." + content_type.split("/")[-1].split(";")[0]
|
|
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
|
|
ext = ".ogg"
|
|
cached_path = await cache_audio_from_url(att.url, ext=ext)
|
|
media_urls.append(cached_path)
|
|
media_types.append(content_type)
|
|
print(f"[Discord] Cached user audio: {cached_path}", flush=True)
|
|
except Exception as e:
|
|
print(f"[Discord] Failed to cache audio attachment: {e}", flush=True)
|
|
media_urls.append(att.url)
|
|
media_types.append(content_type)
|
|
else:
|
|
# Other attachments: keep the original URL
|
|
media_urls.append(att.url)
|
|
media_types.append(content_type)
|
|
|
|
event = MessageEvent(
|
|
text=message.content,
|
|
message_type=msg_type,
|
|
source=source,
|
|
raw_message=message,
|
|
message_id=str(message.id),
|
|
media_urls=media_urls,
|
|
media_types=media_types,
|
|
reply_to_message_id=str(message.reference.message_id) if message.reference else None,
|
|
timestamp=message.created_at,
|
|
)
|
|
|
|
await self.handle_message(event)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discord UI Components (outside the adapter class)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if DISCORD_AVAILABLE:
|
|
|
|
class ExecApprovalView(discord.ui.View):
|
|
"""
|
|
Interactive button view for exec approval of dangerous commands.
|
|
|
|
Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red).
|
|
Only users in the allowed list can click. The view times out after 5 minutes.
|
|
"""
|
|
|
|
def __init__(self, approval_id: str, allowed_user_ids: set):
|
|
super().__init__(timeout=300) # 5-minute timeout
|
|
self.approval_id = approval_id
|
|
self.allowed_user_ids = allowed_user_ids
|
|
self.resolved = False
|
|
|
|
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
|
"""Verify the user clicking is authorized."""
|
|
if not self.allowed_user_ids:
|
|
return True # No allowlist = anyone can approve
|
|
return str(interaction.user.id) in self.allowed_user_ids
|
|
|
|
async def _resolve(
|
|
self, interaction: discord.Interaction, action: str, color: discord.Color
|
|
):
|
|
"""Resolve the approval and update the message."""
|
|
if self.resolved:
|
|
await interaction.response.send_message(
|
|
"This approval has already been resolved~", ephemeral=True
|
|
)
|
|
return
|
|
|
|
if not self._check_auth(interaction):
|
|
await interaction.response.send_message(
|
|
"You're not authorized to approve commands~", ephemeral=True
|
|
)
|
|
return
|
|
|
|
self.resolved = True
|
|
|
|
# Update the embed with the decision
|
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
|
if embed:
|
|
embed.color = color
|
|
embed.set_footer(text=f"{action} by {interaction.user.display_name}")
|
|
|
|
# Disable all buttons
|
|
for child in self.children:
|
|
child.disabled = True
|
|
|
|
await interaction.response.edit_message(embed=embed, view=self)
|
|
|
|
# Store the approval decision
|
|
try:
|
|
from tools.approval import approve_permanent
|
|
if action == "allow_once":
|
|
pass # One-time approval handled by gateway
|
|
elif action == "allow_always":
|
|
approve_permanent(self.approval_id)
|
|
except ImportError:
|
|
pass
|
|
|
|
@discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
|
|
async def allow_once(
|
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
|
):
|
|
await self._resolve(interaction, "allow_once", discord.Color.green())
|
|
|
|
@discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple)
|
|
async def allow_always(
|
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
|
):
|
|
await self._resolve(interaction, "allow_always", discord.Color.blue())
|
|
|
|
@discord.ui.button(label="Deny", style=discord.ButtonStyle.red)
|
|
async def deny(
|
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
|
):
|
|
await self._resolve(interaction, "deny", discord.Color.red())
|
|
|
|
async def on_timeout(self):
|
|
"""Handle view timeout -- disable buttons and mark as expired."""
|
|
self.resolved = True
|
|
for child in self.children:
|
|
child.disabled = True
|