stage-4: discord bot minimal with channel/admin management

Discord.py bot with slash commands (/ping, /help, /setup, /channel, /admin), PID file, graceful shutdown. 30 new tests (119 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 12:42:28 +00:00
parent 339866baa1
commit 6cd155b71e
3 changed files with 659 additions and 0 deletions

198
src/adapters/discord_bot.py Normal file
View File

@@ -0,0 +1,198 @@
"""Discord bot adapter — slash commands and event handlers."""
import logging
import discord
from discord import app_commands
from src.config import Config
logger = logging.getLogger("echo-core.discord")
# Module-level config reference, set by create_bot()
_config: Config | None = None
def _get_config() -> Config:
"""Return the module-level config, raising if not initialized."""
if _config is None:
raise RuntimeError("Bot not initialized — call create_bot() first")
return _config
# --- Authorization helpers ---
def is_owner(user_id: str) -> bool:
"""Check if user_id matches config bot.owner."""
return _get_config().get("bot.owner") == user_id
def is_admin(user_id: str) -> bool:
"""Check if user_id is owner or in admins list."""
if is_owner(user_id):
return True
admins = _get_config().get("bot.admins", [])
return user_id in admins
def is_registered_channel(channel_id: str) -> bool:
"""Check if channel_id is in any registered channel entry."""
channels = _get_config().get("channels", {})
return any(ch.get("id") == channel_id for ch in channels.values())
# --- Factory ---
def create_bot(config: Config) -> discord.Client:
"""Create and configure the Discord bot with all slash commands."""
global _config
_config = config
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
client.tree = tree # type: ignore[attr-defined]
# --- Slash commands ---
@tree.command(name="ping", description="Check bot latency")
async def ping(interaction: discord.Interaction) -> None:
latency_ms = round(client.latency * 1000)
await interaction.response.send_message(
f"Pong! Latency: {latency_ms}ms", ephemeral=True
)
@tree.command(name="help", description="List available commands")
async def help_cmd(interaction: discord.Interaction) -> None:
lines = [
"**Echo Commands**",
"`/ping` — Check bot latency",
"`/help` — Show this help message",
"`/setup` — Claim ownership of the bot (first run only)",
"`/channel add <alias>` — Register current channel (owner only)",
"`/channels` — List registered channels",
"`/admin add <user_id>` — Add an admin (owner only)",
]
await interaction.response.send_message(
"\n".join(lines), ephemeral=True
)
@tree.command(name="setup", description="Claim ownership of the bot")
async def setup(interaction: discord.Interaction) -> None:
if config.get("bot.owner") is not None:
await interaction.response.send_message(
"Owner already set.", ephemeral=True
)
return
config.set("bot.owner", str(interaction.user.id))
config.save()
await interaction.response.send_message(
"You are now the owner of Echo.", ephemeral=True
)
channel_group = app_commands.Group(
name="channel", description="Channel management"
)
@channel_group.command(name="add", description="Register current channel")
@app_commands.describe(alias="Short name for this channel")
async def channel_add(
interaction: discord.Interaction, alias: str
) -> None:
if not is_owner(str(interaction.user.id)):
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
return
config.set(
f"channels.{alias}",
{"id": str(interaction.channel_id), "default_model": "sonnet"},
)
config.save()
await interaction.response.send_message(
f"Channel registered as '{alias}'.", ephemeral=True
)
tree.add_command(channel_group)
admin_group = app_commands.Group(
name="admin", description="Admin management"
)
@admin_group.command(name="add", description="Add an admin user")
@app_commands.describe(user_id="Discord user ID to add as admin")
async def admin_add(
interaction: discord.Interaction, user_id: str
) -> None:
if not is_owner(str(interaction.user.id)):
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
return
admins = config.get("bot.admins", [])
if user_id not in admins:
admins.append(user_id)
config.set("bot.admins", admins)
config.save()
await interaction.response.send_message(
f"User {user_id} added as admin.", ephemeral=True
)
tree.add_command(admin_group)
@tree.command(name="channels", description="List registered channels")
async def channels(interaction: discord.Interaction) -> None:
ch_map = config.get("channels", {})
if not ch_map:
await interaction.response.send_message(
"No channels registered yet.", ephemeral=True
)
return
lines = []
for alias, info in ch_map.items():
cid = info.get("id", "?")
model = info.get("default_model", "?")
lines.append(f"\u2022 {alias} \u2192 <#{cid}> (model: {model})")
await interaction.response.send_message(
"\n".join(lines), ephemeral=True
)
# --- Events ---
@client.event
async def on_ready() -> None:
await tree.sync()
logger.info("Echo Core online as %s", client.user)
@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
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
# Guild messages: ignore if channel not registered
if not is_registered_channel(str(message.channel.id)):
return
logger.info(
"Message in registered channel %s from %s: %s",
message.channel,
message.author,
message.content[:100],
)
# Stage 5 will add chat integration here
return client