feat: openclaw-style secrets (env.vars + \) and per-task model routing
- Replace python-dotenv with config.json env.vars block + \ substitution - Add models section for per-task model routing (heartbeat, subagent, default) - Heartbeat/subagent tasks can use different models/providers than main chat - Remove python-dotenv from dependencies - Update all docs to reflect new config approach - Reorganize docs into project/ and research/ subdirectories
This commit is contained in:
@@ -4,34 +4,37 @@ Aetheel Discord Adapter
|
||||
Connects to Discord via the Bot Gateway using discord.py.
|
||||
|
||||
Features:
|
||||
- Receives DMs and @mentions in guild channels
|
||||
- Each channel = persistent conversation context
|
||||
- Sends replies back to the same channel (threaded if supported)
|
||||
- DMs and @mentions in guild channels
|
||||
- Reply threading (off / first / all)
|
||||
- Channel history context injection (configurable per-channel)
|
||||
- Ack reactions while processing
|
||||
- Typing indicators while processing
|
||||
- Reaction handling (off / own / all)
|
||||
- Interactive components (buttons, selects, modals)
|
||||
- Native slash commands (/ask, /status, /help)
|
||||
- Exec approval buttons for dangerous AI tool use
|
||||
- Chunked replies for Discord's 2000-char limit
|
||||
- Extends BaseAdapter for multi-channel support
|
||||
|
||||
Setup:
|
||||
1. Create a bot at https://discord.com/developers/applications
|
||||
2. Enable MESSAGE CONTENT intent in Bot settings
|
||||
3. Set DISCORD_BOT_TOKEN in .env
|
||||
4. Invite bot with: OAuth2 → URL Generator → bot scope + Send Messages + Read Message History
|
||||
4. Invite bot with: OAuth2 → URL Generator → bot + applications.commands scope
|
||||
5. Start with: python main.py --discord
|
||||
|
||||
Usage:
|
||||
from adapters.discord_adapter import DiscordAdapter
|
||||
|
||||
adapter = DiscordAdapter()
|
||||
adapter.on_message(my_handler)
|
||||
adapter.start()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from adapters.base import BaseAdapter, IncomingMessage
|
||||
|
||||
@@ -44,22 +47,76 @@ def resolve_discord_token(explicit: str | None = None) -> str:
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"Discord bot token is required. "
|
||||
"Set DISCORD_BOT_TOKEN environment variable or pass it explicitly. "
|
||||
"Get one from https://discord.com/developers/applications"
|
||||
"Set DISCORD_BOT_TOKEN environment variable or pass it explicitly."
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exec Approval State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ExecApprovalStore:
|
||||
"""Thread-safe store for pending exec approval requests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: dict[str, asyncio.Future[bool]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def create(self, approval_id: str, loop: asyncio.AbstractEventLoop) -> asyncio.Future[bool]:
|
||||
future = loop.create_future()
|
||||
with self._lock:
|
||||
self._pending[approval_id] = future
|
||||
return future
|
||||
|
||||
def resolve(self, approval_id: str, approved: bool) -> bool:
|
||||
with self._lock:
|
||||
future = self._pending.pop(approval_id, None)
|
||||
if future and not future.done():
|
||||
future.get_loop().call_soon_threadsafe(future.set_result, approved)
|
||||
return True
|
||||
return False
|
||||
|
||||
def cancel(self, approval_id: str) -> None:
|
||||
with self._lock:
|
||||
future = self._pending.pop(approval_id, None)
|
||||
if future and not future.done():
|
||||
future.get_loop().call_soon_threadsafe(future.set_result, False)
|
||||
|
||||
|
||||
_approval_store = _ExecApprovalStore()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DiscordAdapter(BaseAdapter):
|
||||
"""
|
||||
Discord channel adapter using discord.py.
|
||||
Discord channel adapter using discord.py with interactive features.
|
||||
|
||||
Handles:
|
||||
- DMs (private messages)
|
||||
- Guild channel messages where the bot is @mentioned
|
||||
Supports: DMs, @mentions, reply threading, history context, ack reactions,
|
||||
typing indicators, reaction handling, slash commands, interactive components,
|
||||
and exec approval buttons.
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str | None = None, listen_channels: list[str] | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str | None = None,
|
||||
listen_channels: list[str] | None = None,
|
||||
reply_to_mode: str = "first",
|
||||
history_enabled: bool = True,
|
||||
history_limit: int = 20,
|
||||
channel_overrides: dict[str, Any] | None = None,
|
||||
ack_reaction: str = "👀",
|
||||
typing_indicator: bool = True,
|
||||
reaction_mode: str = "own",
|
||||
exec_approvals: bool = False,
|
||||
exec_approval_tools: list[str] | None = None,
|
||||
slash_commands: bool = True,
|
||||
components_enabled: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self._token = resolve_discord_token(bot_token)
|
||||
self._bot_user_id: int = 0
|
||||
@@ -68,8 +125,20 @@ class DiscordAdapter(BaseAdapter):
|
||||
self._thread: threading.Thread | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# Channels where the bot responds to ALL messages (no @mention needed).
|
||||
# Set via DISCORD_LISTEN_CHANNELS env var (comma-separated IDs) or constructor.
|
||||
# Config
|
||||
self._reply_to_mode = reply_to_mode
|
||||
self._history_enabled = history_enabled
|
||||
self._history_limit = history_limit
|
||||
self._channel_overrides = channel_overrides or {}
|
||||
self._ack_reaction = ack_reaction
|
||||
self._typing_indicator = typing_indicator
|
||||
self._reaction_mode = reaction_mode
|
||||
self._exec_approvals = exec_approvals
|
||||
self._exec_approval_tools = exec_approval_tools or ["Bash", "Write", "Edit"]
|
||||
self._slash_commands = slash_commands
|
||||
self._components_enabled = components_enabled
|
||||
|
||||
# Listen channels (no @mention needed)
|
||||
if listen_channels is not None:
|
||||
self._listen_channels: set[str] = set(listen_channels)
|
||||
else:
|
||||
@@ -78,12 +147,15 @@ class DiscordAdapter(BaseAdapter):
|
||||
ch.strip() for ch in raw.split(",") if ch.strip()
|
||||
}
|
||||
|
||||
# Set up intents — need message content for reading messages
|
||||
# Set up intents
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.dm_messages = True
|
||||
intents.reactions = True
|
||||
intents.members = True
|
||||
|
||||
self._client = discord.Client(intents=intents)
|
||||
self._tree = app_commands.CommandTree(self._client)
|
||||
self._register_handlers()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -95,13 +167,11 @@ class DiscordAdapter(BaseAdapter):
|
||||
return "discord"
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the Discord adapter (blocking)."""
|
||||
logger.info("Starting Discord adapter...")
|
||||
self._running = True
|
||||
self._client.run(self._token, log_handler=None)
|
||||
|
||||
def start_async(self) -> None:
|
||||
"""Start the adapter in a background thread (non-blocking)."""
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_in_thread, daemon=True, name="discord-adapter"
|
||||
)
|
||||
@@ -109,7 +179,6 @@ class DiscordAdapter(BaseAdapter):
|
||||
logger.info("Discord adapter started in background thread")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the Discord adapter gracefully."""
|
||||
self._running = False
|
||||
if self._loop and not self._loop.is_closed():
|
||||
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop)
|
||||
@@ -120,8 +189,10 @@ class DiscordAdapter(BaseAdapter):
|
||||
channel_id: str,
|
||||
text: str,
|
||||
thread_id: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
components: list[discord.ui.View] | None = None,
|
||||
) -> None:
|
||||
"""Send a message to a Discord channel or DM."""
|
||||
"""Send a message with optional reply threading and components."""
|
||||
if not text.strip():
|
||||
return
|
||||
|
||||
@@ -134,9 +205,32 @@ class DiscordAdapter(BaseAdapter):
|
||||
logger.error(f"Channel {channel_id} not found")
|
||||
return
|
||||
|
||||
# Build reference for reply threading
|
||||
reference = None
|
||||
if reply_to_message_id and self._reply_to_mode != "off":
|
||||
try:
|
||||
reference = discord.MessageReference(
|
||||
message_id=reply_to_message_id,
|
||||
channel_id=int(channel_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
chunks = _chunk_text(text, 2000)
|
||||
for chunk in chunks:
|
||||
await target.send(chunk)
|
||||
view = components[0] if components and self._components_enabled else None
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
# Only attach reference to first chunk, view to last chunk
|
||||
ref = reference if i == 0 else None
|
||||
v = view if i == len(chunks) - 1 else None
|
||||
try:
|
||||
await target.send(chunk, reference=ref, view=v)
|
||||
except discord.HTTPException as e:
|
||||
# If reply reference fails (deleted message), send without it
|
||||
if ref and e.code == 50035:
|
||||
await target.send(chunk, view=v)
|
||||
else:
|
||||
raise
|
||||
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(_send(), self._loop)
|
||||
@@ -144,7 +238,193 @@ class DiscordAdapter(BaseAdapter):
|
||||
asyncio.run(_send())
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Internal: Event handlers
|
||||
# History Context
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _fetch_history(self, channel: Any, channel_id: str) -> str:
|
||||
"""Fetch recent message history for context injection."""
|
||||
# Check per-channel override
|
||||
override = self._channel_overrides.get(channel_id, {})
|
||||
if isinstance(override, dict):
|
||||
ch_enabled = override.get("history_enabled", self._history_enabled)
|
||||
ch_limit = override.get("history_limit", self._history_limit)
|
||||
else:
|
||||
ch_enabled = getattr(override, "history_enabled", None)
|
||||
if ch_enabled is None:
|
||||
ch_enabled = self._history_enabled
|
||||
ch_limit = getattr(override, "history_limit", None)
|
||||
if ch_limit is None:
|
||||
ch_limit = self._history_limit
|
||||
|
||||
if not ch_enabled or ch_limit <= 0:
|
||||
return ""
|
||||
|
||||
try:
|
||||
messages: list[str] = []
|
||||
async for msg in channel.history(limit=ch_limit):
|
||||
if msg.author == self._client.user:
|
||||
role = "assistant"
|
||||
else:
|
||||
role = msg.author.display_name or msg.author.name
|
||||
content = msg.content or ""
|
||||
if content:
|
||||
messages.append(f"[{role}]: {content[:500]}")
|
||||
messages.reverse()
|
||||
if messages:
|
||||
return "\n".join(messages)
|
||||
except (discord.Forbidden, discord.HTTPException) as e:
|
||||
logger.debug(f"Could not fetch history for {channel_id}: {e}")
|
||||
return ""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Ack Reaction & Typing
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _add_ack_reaction(self, message: discord.Message) -> bool:
|
||||
"""Add ack reaction to message. Returns True if added."""
|
||||
if not self._ack_reaction:
|
||||
return False
|
||||
try:
|
||||
await message.add_reaction(self._ack_reaction)
|
||||
return True
|
||||
except (discord.Forbidden, discord.HTTPException) as e:
|
||||
logger.debug(f"Could not add ack reaction: {e}")
|
||||
return False
|
||||
|
||||
async def _remove_ack_reaction(self, message: discord.Message) -> None:
|
||||
"""Remove ack reaction from message."""
|
||||
if not self._ack_reaction:
|
||||
return
|
||||
try:
|
||||
await message.remove_reaction(self._ack_reaction, self._client.user)
|
||||
except (discord.Forbidden, discord.HTTPException, discord.NotFound):
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Exec Approvals
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _request_exec_approval(
|
||||
self,
|
||||
channel: Any,
|
||||
tool_name: str,
|
||||
description: str,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
"""Send an approval button prompt and wait for user response."""
|
||||
if not self._exec_approvals or not self._loop:
|
||||
return True # Auto-approve if disabled
|
||||
|
||||
if tool_name not in self._exec_approval_tools:
|
||||
return True # Not a gated tool
|
||||
|
||||
approval_id = str(uuid.uuid4())[:8]
|
||||
view = _ExecApprovalView(approval_id, user_id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚠️ Exec Approval Required",
|
||||
description=f"**Tool:** `{tool_name}`\n**Action:** {description[:500]}",
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
embed.set_footer(text=f"Only <@{user_id}> can approve • ID: {approval_id}")
|
||||
|
||||
try:
|
||||
await channel.send(embed=embed, view=view)
|
||||
except discord.HTTPException as e:
|
||||
logger.error(f"Failed to send approval prompt: {e}")
|
||||
return True # Fail open
|
||||
|
||||
future = _approval_store.create(approval_id, self._loop)
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=120.0)
|
||||
except asyncio.TimeoutError:
|
||||
_approval_store.cancel(approval_id)
|
||||
try:
|
||||
await channel.send(f"⏰ Approval `{approval_id}` timed out — action denied.")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Interactive Components: Send with buttons/selects
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def send_components_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
buttons: list[dict[str, Any]] | None = None,
|
||||
select_options: list[dict[str, str]] | None = None,
|
||||
callback: Callable[[discord.Interaction, str], Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a message with interactive buttons or a select menu."""
|
||||
if not self._components_enabled:
|
||||
# Fall back to plain text
|
||||
self.send_message(channel_id, text)
|
||||
return
|
||||
|
||||
async def _send():
|
||||
target = self._client.get_channel(int(channel_id))
|
||||
if target is None:
|
||||
try:
|
||||
target = await self._client.fetch_channel(int(channel_id))
|
||||
except discord.NotFound:
|
||||
return
|
||||
|
||||
view = discord.ui.View(timeout=300)
|
||||
|
||||
if buttons:
|
||||
for btn_cfg in buttons[:5]: # Discord max 5 buttons per row
|
||||
style_map = {
|
||||
"primary": discord.ButtonStyle.primary,
|
||||
"secondary": discord.ButtonStyle.secondary,
|
||||
"success": discord.ButtonStyle.success,
|
||||
"danger": discord.ButtonStyle.danger,
|
||||
}
|
||||
style = style_map.get(btn_cfg.get("style", "primary"), discord.ButtonStyle.primary)
|
||||
button = discord.ui.Button(
|
||||
label=btn_cfg.get("label", "Button"),
|
||||
style=style,
|
||||
custom_id=btn_cfg.get("custom_id", str(uuid.uuid4())[:8]),
|
||||
)
|
||||
|
||||
async def _btn_callback(interaction: discord.Interaction, cid=button.custom_id):
|
||||
await interaction.response.defer()
|
||||
if callback:
|
||||
await callback(interaction, cid)
|
||||
|
||||
button.callback = _btn_callback
|
||||
view.add_item(button)
|
||||
|
||||
if select_options:
|
||||
options = [
|
||||
discord.SelectOption(
|
||||
label=opt.get("label", "Option"),
|
||||
value=opt.get("value", opt.get("label", "option")),
|
||||
description=opt.get("description"),
|
||||
)
|
||||
for opt in select_options[:25] # Discord max 25 options
|
||||
]
|
||||
select = discord.ui.Select(
|
||||
placeholder="Choose an option...",
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def _select_callback(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
if callback:
|
||||
await callback(interaction, select.values[0] if select.values else "")
|
||||
|
||||
select.callback = _select_callback
|
||||
view.add_item(select)
|
||||
|
||||
await target.send(text, view=view)
|
||||
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(_send(), self._loop)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Event Handlers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
@@ -158,51 +438,67 @@ class DiscordAdapter(BaseAdapter):
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._running = True
|
||||
|
||||
# Register slash commands
|
||||
if self._slash_commands:
|
||||
await self._register_slash_commands()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Aetheel Discord Adapter")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})")
|
||||
logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})")
|
||||
guilds = [g.name for g in self._client.guilds]
|
||||
logger.info(f" Guilds: {', '.join(guilds) or 'none'}")
|
||||
logger.info(f" Handlers: {len(self._message_handlers)} registered")
|
||||
logger.info(f" Guilds: {', '.join(guilds) or 'none'}")
|
||||
logger.info(f" Handlers: {len(self._message_handlers)} registered")
|
||||
logger.info(f" Reply: {self._reply_to_mode}")
|
||||
logger.info(f" History: {'on' if self._history_enabled else 'off'} (limit={self._history_limit})")
|
||||
logger.info(f" Ack: {self._ack_reaction or 'off'}")
|
||||
logger.info(f" Typing: {'on' if self._typing_indicator else 'off'}")
|
||||
logger.info(f" Reactions: {self._reaction_mode}")
|
||||
logger.info(f" Slash: {'on' if self._slash_commands else 'off'}")
|
||||
logger.info(f" Components:{'on' if self._components_enabled else 'off'}")
|
||||
logger.info(f" Approvals: {'on' if self._exec_approvals else 'off'}")
|
||||
if self._listen_channels:
|
||||
logger.info(f" Listen: {', '.join(self._listen_channels)} (no @mention needed)")
|
||||
logger.info(f" Listen: {', '.join(self._listen_channels)}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
@self._client.event
|
||||
async def on_message(message: discord.Message):
|
||||
# Ignore own messages
|
||||
if message.author == self._client.user:
|
||||
return
|
||||
# Ignore other bots
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
is_dm = isinstance(message.channel, discord.DMChannel)
|
||||
text = message.content
|
||||
|
||||
# In guild channels: respond to @mentions everywhere,
|
||||
# and respond to ALL messages in listen channels (no @mention needed).
|
||||
if not is_dm:
|
||||
channel_str = str(message.channel.id)
|
||||
is_listen_channel = channel_str in self._listen_channels
|
||||
|
||||
if self._client.user and self._client.user.mentioned_in(message):
|
||||
# Strip the mention from the text
|
||||
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
|
||||
text = text.replace(f"<@!{self._bot_user_id}>", "").strip()
|
||||
elif not is_listen_channel:
|
||||
return # Not mentioned and not a listen channel — ignore
|
||||
return
|
||||
|
||||
if not text.strip():
|
||||
return
|
||||
|
||||
# Build IncomingMessage
|
||||
user_name = message.author.display_name or message.author.name
|
||||
if is_dm:
|
||||
channel_name = f"DM with {user_name}"
|
||||
else:
|
||||
channel_name = getattr(message.channel, "name", str(message.channel.id))
|
||||
channel_name = (
|
||||
f"DM with {user_name}" if is_dm
|
||||
else getattr(message.channel, "name", str(message.channel.id))
|
||||
)
|
||||
|
||||
# Ack reaction
|
||||
ack_added = await self._add_ack_reaction(message)
|
||||
|
||||
# Fetch history context
|
||||
history_context = ""
|
||||
if not is_dm:
|
||||
history_context = await self._fetch_history(
|
||||
message.channel, str(message.channel.id)
|
||||
)
|
||||
|
||||
msg = IncomingMessage(
|
||||
text=text,
|
||||
@@ -213,31 +509,259 @@ class DiscordAdapter(BaseAdapter):
|
||||
conversation_id=str(message.channel.id),
|
||||
source="discord",
|
||||
is_dm=is_dm,
|
||||
timestamp=message.created_at.replace(tzinfo=timezone.utc)
|
||||
if message.created_at.tzinfo is None
|
||||
else message.created_at,
|
||||
timestamp=(
|
||||
message.created_at.replace(tzinfo=timezone.utc)
|
||||
if message.created_at.tzinfo is None
|
||||
else message.created_at
|
||||
),
|
||||
raw_event={
|
||||
"thread_id": None,
|
||||
"message_id": message.id,
|
||||
"guild_id": message.guild.id if message.guild else None,
|
||||
"history_context": history_context,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"📨 [Discord] Message from {user_name} in {channel_name}: "
|
||||
f"{text[:100]}"
|
||||
f"📨 [Discord] {user_name} in {channel_name}: {text[:100]}"
|
||||
)
|
||||
|
||||
# Dispatch synchronously in a thread to avoid blocking the event loop
|
||||
# (handlers call subprocess-based AI runtimes which are blocking)
|
||||
# Process with typing indicator
|
||||
async def _process():
|
||||
try:
|
||||
if self._typing_indicator:
|
||||
async with message.channel.typing():
|
||||
response = await asyncio.to_thread(self._dispatch_and_capture, msg)
|
||||
else:
|
||||
response = await asyncio.to_thread(self._dispatch_and_capture, msg)
|
||||
finally:
|
||||
if ack_added:
|
||||
await self._remove_ack_reaction(message)
|
||||
|
||||
if response:
|
||||
# Determine reply reference based on mode
|
||||
reply_to_id = None
|
||||
if self._reply_to_mode == "first":
|
||||
reply_to_id = message.id
|
||||
elif self._reply_to_mode == "all":
|
||||
reply_to_id = message.id
|
||||
|
||||
await self._send_async(
|
||||
str(message.channel.id), response, reply_to_id
|
||||
)
|
||||
|
||||
await _process()
|
||||
|
||||
@self._client.event
|
||||
async def on_reaction_add(reaction: discord.Reaction, user: discord.User):
|
||||
"""Handle reaction events based on reaction_mode."""
|
||||
if self._reaction_mode == "off":
|
||||
return
|
||||
if user == self._client.user:
|
||||
return
|
||||
if user.bot:
|
||||
return
|
||||
|
||||
# "own" = only reactions on bot's messages
|
||||
if self._reaction_mode == "own":
|
||||
if reaction.message.author != self._client.user:
|
||||
return
|
||||
|
||||
emoji_str = str(reaction.emoji)
|
||||
user_name = getattr(user, "display_name", user.name)
|
||||
channel_id = str(reaction.message.channel.id)
|
||||
channel_name = getattr(
|
||||
reaction.message.channel, "name", channel_id
|
||||
)
|
||||
|
||||
msg = IncomingMessage(
|
||||
text=f"[Reaction: {emoji_str} on message: {reaction.message.content[:200] if reaction.message.content else '(no text)'}]",
|
||||
user_id=str(user.id),
|
||||
user_name=user_name,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
conversation_id=channel_id,
|
||||
source="discord",
|
||||
is_dm=isinstance(reaction.message.channel, discord.DMChannel),
|
||||
raw_event={
|
||||
"type": "reaction",
|
||||
"emoji": emoji_str,
|
||||
"message_id": reaction.message.id,
|
||||
"original_text": reaction.message.content or "",
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"👍 [Discord] {user_name} reacted {emoji_str} in {channel_name}"
|
||||
)
|
||||
await asyncio.to_thread(self._dispatch, msg)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Slash Commands
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _register_slash_commands(self) -> None:
|
||||
"""Register Discord slash commands."""
|
||||
|
||||
@self._tree.command(name="ask", description="Ask Aetheel a question")
|
||||
async def slash_ask(interaction: discord.Interaction, message: str):
|
||||
await interaction.response.defer(thinking=True)
|
||||
|
||||
msg = IncomingMessage(
|
||||
text=message,
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name or interaction.user.name,
|
||||
channel_id=str(interaction.channel_id),
|
||||
channel_name=getattr(interaction.channel, "name", str(interaction.channel_id)),
|
||||
conversation_id=str(interaction.channel_id),
|
||||
source="discord",
|
||||
is_dm=interaction.guild is None,
|
||||
raw_event={"type": "slash_command", "command": "ask"},
|
||||
)
|
||||
|
||||
response = await asyncio.to_thread(self._dispatch_and_capture, msg)
|
||||
text = response or "No response."
|
||||
chunks = _chunk_text(text, 2000)
|
||||
await interaction.followup.send(chunks[0])
|
||||
for chunk in chunks[1:]:
|
||||
await interaction.followup.send(chunk)
|
||||
|
||||
@self._tree.command(name="status", description="Check Aetheel status")
|
||||
async def slash_status(interaction: discord.Interaction):
|
||||
msg = IncomingMessage(
|
||||
text="status",
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name or interaction.user.name,
|
||||
channel_id=str(interaction.channel_id),
|
||||
channel_name=getattr(interaction.channel, "name", str(interaction.channel_id)),
|
||||
conversation_id=str(interaction.channel_id),
|
||||
source="discord",
|
||||
is_dm=interaction.guild is None,
|
||||
raw_event={"type": "slash_command", "command": "status"},
|
||||
)
|
||||
response = await asyncio.to_thread(self._dispatch_and_capture, msg)
|
||||
await interaction.response.send_message(
|
||||
_chunk_text(response or "No status.", 2000)[0]
|
||||
)
|
||||
|
||||
@self._tree.command(name="help", description="Show Aetheel help")
|
||||
async def slash_help(interaction: discord.Interaction):
|
||||
msg = IncomingMessage(
|
||||
text="help",
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name or interaction.user.name,
|
||||
channel_id=str(interaction.channel_id),
|
||||
channel_name=getattr(interaction.channel, "name", str(interaction.channel_id)),
|
||||
conversation_id=str(interaction.channel_id),
|
||||
source="discord",
|
||||
is_dm=interaction.guild is None,
|
||||
raw_event={"type": "slash_command", "command": "help"},
|
||||
)
|
||||
response = await asyncio.to_thread(self._dispatch_and_capture, msg)
|
||||
await interaction.response.send_message(
|
||||
_chunk_text(response or "No help available.", 2000)[0]
|
||||
)
|
||||
|
||||
try:
|
||||
synced = await self._tree.sync()
|
||||
logger.info(f"Synced {len(synced)} slash command(s)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync slash commands: {e}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _dispatch_and_capture(self, msg: IncomingMessage) -> str | None:
|
||||
"""Dispatch to handlers and return the first response."""
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
response = handler(msg)
|
||||
if response:
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"[discord] Handler error: {e}", exc_info=True)
|
||||
return "⚠️ Something went wrong processing your message."
|
||||
return None
|
||||
|
||||
async def _send_async(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> None:
|
||||
"""Send a message asynchronously (called from within the event loop)."""
|
||||
target = self._client.get_channel(int(channel_id))
|
||||
if target is None:
|
||||
try:
|
||||
target = await self._client.fetch_channel(int(channel_id))
|
||||
except discord.NotFound:
|
||||
logger.error(f"Channel {channel_id} not found")
|
||||
return
|
||||
|
||||
reference = None
|
||||
if reply_to_message_id and self._reply_to_mode != "off":
|
||||
reference = discord.MessageReference(
|
||||
message_id=reply_to_message_id,
|
||||
channel_id=int(channel_id),
|
||||
)
|
||||
|
||||
chunks = _chunk_text(text, 2000)
|
||||
for i, chunk in enumerate(chunks):
|
||||
ref = reference if i == 0 else None
|
||||
try:
|
||||
await target.send(chunk, reference=ref)
|
||||
except discord.HTTPException as e:
|
||||
if ref and e.code == 50035:
|
||||
await target.send(chunk)
|
||||
else:
|
||||
raise
|
||||
|
||||
def _run_in_thread(self) -> None:
|
||||
"""Run the Discord client in a dedicated thread with its own event loop."""
|
||||
self._running = True
|
||||
self._client.run(self._token, log_handler=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exec Approval View
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ExecApprovalView(discord.ui.View):
|
||||
"""Button view for exec approval prompts."""
|
||||
|
||||
def __init__(self, approval_id: str, allowed_user_id: str):
|
||||
super().__init__(timeout=120)
|
||||
self._approval_id = approval_id
|
||||
self._allowed_user_id = allowed_user_id
|
||||
|
||||
@discord.ui.button(label="Approve", style=discord.ButtonStyle.success, emoji="✅")
|
||||
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if str(interaction.user.id) != self._allowed_user_id:
|
||||
await interaction.response.send_message(
|
||||
"Only the original requester can approve.", ephemeral=True
|
||||
)
|
||||
return
|
||||
_approval_store.resolve(self._approval_id, True)
|
||||
await interaction.response.edit_message(
|
||||
content="✅ Approved", view=None
|
||||
)
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button(label="Deny", style=discord.ButtonStyle.danger, emoji="❌")
|
||||
async def deny(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if str(interaction.user.id) != self._allowed_user_id:
|
||||
await interaction.response.send_message(
|
||||
"Only the original requester can deny.", ephemeral=True
|
||||
)
|
||||
return
|
||||
_approval_store.resolve(self._approval_id, False)
|
||||
await interaction.response.edit_message(
|
||||
content="❌ Denied", view=None
|
||||
)
|
||||
self.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user