stage-5: full discord-claude chat integration
Message router, typing indicator, emoji reactions, auto start/resume sessions, message splitting >2000 chars. 34 new tests (141 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""Discord bot adapter — slash commands and event handlers."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from src.config import Config
|
||||
from src.claude_session import clear_session, get_active_session
|
||||
from src.router import route_message
|
||||
|
||||
logger = logging.getLogger("echo-core.discord")
|
||||
|
||||
@@ -42,6 +45,28 @@ def is_registered_channel(channel_id: str) -> bool:
|
||||
return any(ch.get("id") == channel_id for ch in channels.values())
|
||||
|
||||
|
||||
# --- Message splitting helper ---
|
||||
|
||||
|
||||
def split_message(text: str, limit: int = 2000) -> list[str]:
|
||||
"""Split text into chunks that fit Discord's message limit."""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
while text:
|
||||
if len(text) <= limit:
|
||||
chunks.append(text)
|
||||
break
|
||||
# Find last newline before limit
|
||||
split_at = text.rfind('\n', 0, limit)
|
||||
if split_at == -1:
|
||||
split_at = limit
|
||||
chunks.append(text[:split_at])
|
||||
text = text[split_at:].lstrip('\n')
|
||||
return chunks
|
||||
|
||||
|
||||
# --- Factory ---
|
||||
|
||||
|
||||
@@ -76,6 +101,8 @@ def create_bot(config: Config) -> discord.Client:
|
||||
"`/channel add <alias>` — Register current channel (owner only)",
|
||||
"`/channels` — List registered channels",
|
||||
"`/admin add <user_id>` — Add an admin (owner only)",
|
||||
"`/clear` — Clear the session for this channel",
|
||||
"`/status` — Show session status for this channel",
|
||||
]
|
||||
await interaction.response.send_message(
|
||||
"\n".join(lines), ephemeral=True
|
||||
@@ -161,6 +188,39 @@ def create_bot(config: Config) -> discord.Client:
|
||||
"\n".join(lines), ephemeral=True
|
||||
)
|
||||
|
||||
@tree.command(name="clear", description="Clear the session for this channel")
|
||||
async def clear(interaction: discord.Interaction) -> None:
|
||||
channel_id = str(interaction.channel_id)
|
||||
removed = clear_session(channel_id)
|
||||
if removed:
|
||||
await interaction.response.send_message(
|
||||
"Session cleared.", ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"No active session for this channel.", ephemeral=True
|
||||
)
|
||||
|
||||
@tree.command(name="status", description="Show session status")
|
||||
async def status(interaction: discord.Interaction) -> None:
|
||||
channel_id = str(interaction.channel_id)
|
||||
session = get_active_session(channel_id)
|
||||
if session is None:
|
||||
await interaction.response.send_message(
|
||||
"No active session.", ephemeral=True
|
||||
)
|
||||
return
|
||||
sid = session.get("session_id", "?")
|
||||
truncated_sid = sid[:8] + "..." if len(sid) > 8 else sid
|
||||
model = session.get("model", "?")
|
||||
count = session.get("message_count", 0)
|
||||
await interaction.response.send_message(
|
||||
f"**Model:** {model}\n"
|
||||
f"**Session:** `{truncated_sid}`\n"
|
||||
f"**Messages:** {count}",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# --- Events ---
|
||||
|
||||
@client.event
|
||||
@@ -168,20 +228,51 @@ def create_bot(config: Config) -> discord.Client:
|
||||
await tree.sync()
|
||||
logger.info("Echo Core online as %s", client.user)
|
||||
|
||||
async def _handle_chat(message: discord.Message) -> None:
|
||||
"""Process a chat message through the router and send the response."""
|
||||
channel_id = str(message.channel.id)
|
||||
user_id = str(message.author.id)
|
||||
text = message.content
|
||||
|
||||
# React to acknowledge receipt
|
||||
await message.add_reaction("\U0001f440")
|
||||
|
||||
try:
|
||||
async with message.channel.typing():
|
||||
response = await asyncio.to_thread(
|
||||
route_message, channel_id, user_id, text
|
||||
)
|
||||
|
||||
chunks = split_message(response)
|
||||
for chunk in chunks:
|
||||
await message.channel.send(chunk)
|
||||
except Exception:
|
||||
logger.exception("Error processing message from %s", message.author)
|
||||
await message.channel.send(
|
||||
"Sorry, something went wrong processing your message."
|
||||
)
|
||||
finally:
|
||||
# Remove the eyes reaction
|
||||
try:
|
||||
await message.remove_reaction("\U0001f440", client.user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@client.event
|
||||
async def on_message(message: discord.Message) -> None:
|
||||
# Ignore bot's own messages
|
||||
if message.author == client.user:
|
||||
return
|
||||
|
||||
# DM handling: ignore if sender not admin
|
||||
# DM handling: only process if sender is admin
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
if not is_admin(str(message.author.id)):
|
||||
return
|
||||
logger.info(
|
||||
"DM from admin %s: %s", message.author, message.content[:100]
|
||||
)
|
||||
return # Stage 5 will add chat integration
|
||||
await _handle_chat(message)
|
||||
return
|
||||
|
||||
# Guild messages: ignore if channel not registered
|
||||
if not is_registered_channel(str(message.channel.id)):
|
||||
@@ -193,6 +284,6 @@ def create_bot(config: Config) -> discord.Client:
|
||||
message.author,
|
||||
message.content[:100],
|
||||
)
|
||||
# Stage 5 will add chat integration here
|
||||
await _handle_chat(message)
|
||||
|
||||
return client
|
||||
|
||||
Reference in New Issue
Block a user