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:
198
src/adapters/discord_bot.py
Normal file
198
src/adapters/discord_bot.py
Normal 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
|
||||
69
src/main.py
Normal file
69
src/main.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Echo Core — main entry point."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.config import load_config
|
||||
from src.secrets import get_secret
|
||||
from src.adapters.discord_bot import create_bot
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
PID_FILE = PROJECT_ROOT / "echo-core.pid"
|
||||
LOG_DIR = PROJECT_ROOT / "logs"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_DIR / "echo-core.log"),
|
||||
logging.StreamHandler(sys.stderr),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
setup_logging()
|
||||
logger = logging.getLogger("echo-core")
|
||||
|
||||
token = get_secret("discord_token")
|
||||
if not token:
|
||||
logger.error(
|
||||
"discord_token not found in keyring. "
|
||||
"Run: python cli.py secrets set discord_token"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
config = load_config()
|
||||
client = create_bot(config)
|
||||
|
||||
# PID file
|
||||
PID_FILE.write_text(str(os.getpid()))
|
||||
|
||||
# Signal handlers for graceful shutdown
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
def handle_signal(sig, frame):
|
||||
logger.info("Received signal %s, shutting down...", sig)
|
||||
loop.create_task(client.close())
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(client.start(token))
|
||||
except KeyboardInterrupt:
|
||||
loop.run_until_complete(client.close())
|
||||
finally:
|
||||
PID_FILE.unlink(missing_ok=True)
|
||||
logger.info("Echo Core shut down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user