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:
MoltBot Service
2026-02-13 12:54:19 +00:00
parent 6cd155b71e
commit a1a6ca9a3f
4 changed files with 467 additions and 3 deletions

View File

@@ -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