Restructurează comenzile Ralph într-un dispatcher unificat (_try_ralph_dispatch) care suportă atât comenzile noi scurte (/p /a /l /k) cât și aliasurile legacy (!propose !approve !status !stop). Pe Discord adaugă slash commands native cu autocomplete dinamic pentru pending (/a) și running (/k). Pe Telegram apar în meniul /. WhatsApp le parsează ca text plain. Activează cron jobs morning-report (08:30) și evening-report (21:00) și adaugă night-execute (23:00) pentru execuția autonomă a proiectelor aprobate. Foundation pentru W1 din planul "Echo Core conversational planning agent". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1061 lines
41 KiB
Python
1061 lines
41 KiB
Python
"""Discord bot adapter — slash commands and event handlers."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
import discord
|
|
from discord import app_commands
|
|
|
|
from src.config import Config
|
|
from src.claude_session import (
|
|
clear_session,
|
|
get_active_session,
|
|
set_session_model,
|
|
PROJECT_ROOT,
|
|
VALID_MODELS,
|
|
)
|
|
from src.fast_commands import dispatch as fast_dispatch
|
|
from src.router import (
|
|
route_message,
|
|
_ralph_propose,
|
|
_ralph_approve,
|
|
_ralph_status,
|
|
_ralph_stop,
|
|
_load_approved_tasks,
|
|
)
|
|
|
|
logger = logging.getLogger("echo-core.discord")
|
|
_security_log = logging.getLogger("echo-core.security")
|
|
|
|
# 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())
|
|
|
|
|
|
def _channel_alias_for_id(channel_id: str) -> str | None:
|
|
"""Resolve a Discord channel ID to its config alias."""
|
|
channels = _get_config().get("channels", {})
|
|
for alias, info in channels.items():
|
|
if info.get("id") == channel_id:
|
|
return alias
|
|
return None
|
|
|
|
|
|
# --- 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 ---
|
|
|
|
|
|
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",
|
|
"`/clear` — Clear the session for this channel",
|
|
"`/status` — Show session status",
|
|
"`/model [choice]` — View/change AI model",
|
|
"",
|
|
"**Email**",
|
|
"`/email check` — Check unread emails",
|
|
"`/email send <to> <subject> <body>` — Send an email",
|
|
"`/email save` — Save unread emails to KB",
|
|
"",
|
|
"**Calendar**",
|
|
"`/calendar today` — Today + tomorrow events",
|
|
"`/calendar week` — This week's schedule",
|
|
"`/calendar busy` — Am I in a meeting?",
|
|
"",
|
|
"**Notes**",
|
|
"`/note <text>` — Quick note in daily file",
|
|
"`/jurnal <text>` — Journal entry in daily file",
|
|
"`/search <query>` — Search Echo's memory",
|
|
"`/kb [category]` — Recent KB notes",
|
|
"",
|
|
"**Reminders**",
|
|
"`/remind <time> <text> [date]` — Create reminder",
|
|
"",
|
|
"**Git**",
|
|
"`/commit [message]` — Commit all changes",
|
|
"`/push` — Push to remote",
|
|
"`/pull` — Pull with rebase",
|
|
"`/test [pattern]` — Run tests",
|
|
"",
|
|
"**Ops**",
|
|
"`/logs [n]` — Last N log lines",
|
|
"`/doctor` — System diagnostics",
|
|
"`/heartbeat` — Health checks",
|
|
"`/restart` — Restart bot (owner)",
|
|
"",
|
|
"**Ralph (autonomous projects)**",
|
|
"`/p <slug> <description>` — Propose new project",
|
|
"`/a [slug]` — Approve for tonight (autocomplete)",
|
|
"`/l` — List projects status",
|
|
"`/k <slug>` — Stop a running project (autocomplete)",
|
|
"",
|
|
"**Admin**",
|
|
"`/setup` — Claim ownership",
|
|
"`/channel add <alias>` — Register channel",
|
|
"`/channels` — List channels",
|
|
"`/admin add <user_id>` — Add admin",
|
|
"`/cron list|run|add|remove|enable|disable` — Cron jobs",
|
|
]
|
|
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)):
|
|
_security_log.warning("Unauthorized owner command /channel add by user=%s (%s)", interaction.user.id, interaction.user)
|
|
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)):
|
|
_security_log.warning("Unauthorized owner command /admin add by user=%s (%s)", interaction.user.id, interaction.user)
|
|
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)
|
|
|
|
# --- Cron commands ---
|
|
|
|
cron_group = app_commands.Group(
|
|
name="cron", description="Manage scheduled jobs"
|
|
)
|
|
|
|
@cron_group.command(name="list", description="List all scheduled jobs")
|
|
async def cron_list(interaction: discord.Interaction) -> None:
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
jobs = scheduler.list_jobs()
|
|
if not jobs:
|
|
await interaction.response.send_message(
|
|
"No scheduled jobs.", ephemeral=True
|
|
)
|
|
return
|
|
lines = [
|
|
f"{'Name':<24} {'Cron':<14} {'Channel':<10} {'Model':<8} {'On':<5} {'Status':<8} {'Next Run'}"
|
|
]
|
|
for j in jobs:
|
|
enabled = "yes" if j.get("enabled") else "no"
|
|
last_status = j.get("last_status") or "\u2014"
|
|
next_run = j.get("next_run") or "\u2014"
|
|
if next_run != "\u2014" and len(next_run) > 19:
|
|
next_run = next_run[:19]
|
|
lines.append(
|
|
f"{j['name']:<24} {j['cron']:<14} {j['channel']:<10} {j['model']:<8} {enabled:<5} {last_status:<8} {next_run}"
|
|
)
|
|
table = "```\n" + "\n".join(lines) + "\n```"
|
|
await interaction.response.send_message(table, ephemeral=True)
|
|
|
|
@cron_group.command(name="run", description="Force-run a scheduled job")
|
|
@app_commands.describe(name="Job name to run")
|
|
async def cron_run(interaction: discord.Interaction, name: str) -> None:
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
await interaction.response.defer()
|
|
try:
|
|
result = await scheduler.run_job(name)
|
|
truncated = result[:1900] if len(result) > 1900 else result
|
|
await interaction.followup.send(truncated)
|
|
except KeyError:
|
|
await interaction.followup.send(f"Job '{name}' not found.")
|
|
except Exception as e:
|
|
await interaction.followup.send(f"Error running job: {e}")
|
|
|
|
@cron_group.command(name="add", description="Create a new scheduled job")
|
|
@app_commands.describe(
|
|
name="Job name (lowercase, hyphens allowed)",
|
|
expression="Cron expression (e.g. '30 6 * * *')",
|
|
model="AI model to use (default: sonnet)",
|
|
)
|
|
@app_commands.choices(model=[
|
|
app_commands.Choice(name="opus", value="opus"),
|
|
app_commands.Choice(name="sonnet", value="sonnet"),
|
|
app_commands.Choice(name="haiku", value="haiku"),
|
|
])
|
|
async def cron_add(
|
|
interaction: discord.Interaction,
|
|
name: str,
|
|
expression: str,
|
|
model: app_commands.Choice[str] | None = None,
|
|
) -> None:
|
|
if not is_admin(str(interaction.user.id)):
|
|
_security_log.warning("Unauthorized admin command /cron add by user=%s (%s)", interaction.user.id, interaction.user)
|
|
await interaction.response.send_message(
|
|
"Admin only.", ephemeral=True
|
|
)
|
|
return
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
channel_alias = _channel_alias_for_id(str(interaction.channel_id))
|
|
if channel_alias is None:
|
|
await interaction.response.send_message(
|
|
"This channel is not registered. Use `/channel add` first.",
|
|
ephemeral=True,
|
|
)
|
|
return
|
|
model_value = model.value if model else "sonnet"
|
|
await interaction.response.send_message(
|
|
f"Creating job **{name}** (`{expression}`, model: {model_value}, channel: {channel_alias}).\n"
|
|
"Send your prompt text as the next message in this channel.",
|
|
)
|
|
|
|
def check(m: discord.Message) -> bool:
|
|
return (
|
|
m.author == interaction.user
|
|
and m.channel.id == interaction.channel_id
|
|
)
|
|
|
|
try:
|
|
prompt_msg = await client.wait_for(
|
|
"message", check=check, timeout=120
|
|
)
|
|
except asyncio.TimeoutError:
|
|
await interaction.followup.send("Timed out waiting for prompt.")
|
|
return
|
|
|
|
try:
|
|
job = scheduler.add_job(
|
|
name=name,
|
|
cron=expression,
|
|
channel=channel_alias,
|
|
prompt=prompt_msg.content,
|
|
model=model_value,
|
|
)
|
|
next_run = job.get("next_run") or "\u2014"
|
|
await interaction.channel.send(
|
|
f"Job **{name}** created.\n"
|
|
f"Cron: `{expression}` | Channel: {channel_alias} | Model: {model_value}\n"
|
|
f"Next run: {next_run}"
|
|
)
|
|
except ValueError as e:
|
|
await interaction.channel.send(f"Error creating job: {e}")
|
|
|
|
@cron_group.command(name="remove", description="Remove a scheduled job")
|
|
@app_commands.describe(name="Job name to remove")
|
|
async def cron_remove(interaction: discord.Interaction, name: str) -> None:
|
|
if not is_admin(str(interaction.user.id)):
|
|
_security_log.warning("Unauthorized admin command /cron remove by user=%s (%s)", interaction.user.id, interaction.user)
|
|
await interaction.response.send_message(
|
|
"Admin only.", ephemeral=True
|
|
)
|
|
return
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
if scheduler.remove_job(name):
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' removed.", ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' not found.", ephemeral=True
|
|
)
|
|
|
|
@cron_group.command(name="enable", description="Enable a scheduled job")
|
|
@app_commands.describe(name="Job name to enable")
|
|
async def cron_enable(
|
|
interaction: discord.Interaction, name: str
|
|
) -> None:
|
|
if not is_admin(str(interaction.user.id)):
|
|
_security_log.warning("Unauthorized admin command /cron enable by user=%s (%s)", interaction.user.id, interaction.user)
|
|
await interaction.response.send_message(
|
|
"Admin only.", ephemeral=True
|
|
)
|
|
return
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
if scheduler.enable_job(name):
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' enabled.", ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' not found.", ephemeral=True
|
|
)
|
|
|
|
@cron_group.command(name="disable", description="Disable a scheduled job")
|
|
@app_commands.describe(name="Job name to disable")
|
|
async def cron_disable(
|
|
interaction: discord.Interaction, name: str
|
|
) -> None:
|
|
if not is_admin(str(interaction.user.id)):
|
|
_security_log.warning("Unauthorized admin command /cron disable by user=%s (%s)", interaction.user.id, interaction.user)
|
|
await interaction.response.send_message(
|
|
"Admin only.", ephemeral=True
|
|
)
|
|
return
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is None:
|
|
await interaction.response.send_message(
|
|
"Scheduler not available.", ephemeral=True
|
|
)
|
|
return
|
|
if scheduler.disable_job(name):
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' disabled.", ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
f"Job '{name}' not found.", ephemeral=True
|
|
)
|
|
|
|
tree.add_command(cron_group)
|
|
|
|
# --- Email commands ---
|
|
|
|
email_group = app_commands.Group(
|
|
name="email", description="Email operations"
|
|
)
|
|
|
|
@email_group.command(name="check", description="Check unread emails")
|
|
async def email_check(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "email", [])
|
|
await interaction.followup.send(result)
|
|
|
|
@email_group.command(name="send", description="Send an email")
|
|
@app_commands.describe(
|
|
to="Recipient email address",
|
|
subject="Email subject",
|
|
body="Email body text",
|
|
)
|
|
async def email_send(
|
|
interaction: discord.Interaction, to: str, subject: str, body: str
|
|
) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(
|
|
fast_dispatch, "email", ["send", f"{to} {subject} :: {body}"]
|
|
)
|
|
await interaction.followup.send(result)
|
|
|
|
@email_group.command(name="save", description="Save unread emails to knowledge base")
|
|
async def email_save(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "email", ["save"])
|
|
await interaction.followup.send(result)
|
|
|
|
@email_group.command(name="digest", description="Procesează emailuri necitite și trimite rezumate pe WhatsApp")
|
|
async def email_digest(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "email", ["digest"])
|
|
await interaction.followup.send(result)
|
|
|
|
@email_group.command(name="forward", description="Forwardează emailuri necitite direct pe WhatsApp fără rezumat")
|
|
async def email_forward(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "email", ["forward"])
|
|
await interaction.followup.send(result)
|
|
|
|
tree.add_command(email_group)
|
|
|
|
# --- Calendar commands ---
|
|
|
|
calendar_group = app_commands.Group(
|
|
name="calendar", description="Calendar operations"
|
|
)
|
|
|
|
@calendar_group.command(name="today", description="Today and tomorrow events")
|
|
async def calendar_today(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "calendar", [])
|
|
await interaction.followup.send(result)
|
|
|
|
@calendar_group.command(name="week", description="This week's schedule")
|
|
async def calendar_week(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "calendar", ["week"])
|
|
await interaction.followup.send(result)
|
|
|
|
@calendar_group.command(name="busy", description="Am I in a meeting right now?")
|
|
async def calendar_busy(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "calendar", ["busy"])
|
|
await interaction.followup.send(result)
|
|
|
|
tree.add_command(calendar_group)
|
|
|
|
# --- Note & journal commands ---
|
|
|
|
@tree.command(name="note", description="Add a quick note to today's daily file")
|
|
@app_commands.describe(text="Note text")
|
|
async def note_cmd(interaction: discord.Interaction, text: str) -> None:
|
|
await interaction.response.defer(ephemeral=True)
|
|
result = await asyncio.to_thread(fast_dispatch, "note", text.split())
|
|
await interaction.followup.send(result, ephemeral=True)
|
|
|
|
@tree.command(name="jurnal", description="Add a journal entry to today's daily file")
|
|
@app_commands.describe(text="Journal entry text")
|
|
async def jurnal_cmd(interaction: discord.Interaction, text: str) -> None:
|
|
await interaction.response.defer(ephemeral=True)
|
|
result = await asyncio.to_thread(fast_dispatch, "jurnal", text.split())
|
|
await interaction.followup.send(result, ephemeral=True)
|
|
|
|
# --- KB command ---
|
|
|
|
@tree.command(name="kb", description="List recent knowledge base notes")
|
|
@app_commands.describe(category="Filter by category (optional)")
|
|
async def kb_cmd(
|
|
interaction: discord.Interaction, category: str | None = None
|
|
) -> None:
|
|
await interaction.response.defer()
|
|
args = [category] if category else []
|
|
result = await asyncio.to_thread(fast_dispatch, "kb", args)
|
|
await interaction.followup.send(result)
|
|
|
|
# --- Remind command ---
|
|
|
|
@tree.command(name="remind", description="Create a calendar reminder")
|
|
@app_commands.describe(
|
|
time="Time in HH:MM format",
|
|
text="Reminder text",
|
|
date="Date in YYYY-MM-DD format (default: today)",
|
|
)
|
|
async def remind_cmd(
|
|
interaction: discord.Interaction, time: str, text: str, date: str | None = None
|
|
) -> None:
|
|
await interaction.response.defer(ephemeral=True)
|
|
args = [date, time, text] if date else [time, text]
|
|
result = await asyncio.to_thread(fast_dispatch, "remind", args)
|
|
await interaction.followup.send(result, ephemeral=True)
|
|
|
|
# --- Git commands ---
|
|
|
|
@tree.command(name="commit", description="Git commit all changes")
|
|
@app_commands.describe(message="Commit message (auto-generated if empty)")
|
|
async def commit_cmd(
|
|
interaction: discord.Interaction, message: str | None = None
|
|
) -> None:
|
|
await interaction.response.defer()
|
|
args = message.split() if message else []
|
|
result = await asyncio.to_thread(fast_dispatch, "commit", args)
|
|
await interaction.followup.send(result)
|
|
|
|
@tree.command(name="push", description="Git push to remote")
|
|
async def push_cmd(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "push", [])
|
|
await interaction.followup.send(result)
|
|
|
|
@tree.command(name="pull", description="Git pull with rebase")
|
|
async def pull_cmd(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer()
|
|
result = await asyncio.to_thread(fast_dispatch, "pull", [])
|
|
await interaction.followup.send(result)
|
|
|
|
# --- Test command ---
|
|
|
|
@tree.command(name="test", description="Run pytest tests")
|
|
@app_commands.describe(pattern="Test filter pattern (optional)")
|
|
async def test_cmd(
|
|
interaction: discord.Interaction, pattern: str | None = None
|
|
) -> None:
|
|
await interaction.response.defer()
|
|
args = [pattern] if pattern else []
|
|
result = await asyncio.to_thread(fast_dispatch, "test", args)
|
|
# Truncate for Discord limit
|
|
if len(result) > 1900:
|
|
result = result[-1900:]
|
|
await interaction.followup.send(f"```\n{result}\n```")
|
|
|
|
# --- Doctor command ---
|
|
|
|
@tree.command(name="doctor", description="System diagnostics")
|
|
async def doctor_cmd(interaction: discord.Interaction) -> None:
|
|
await interaction.response.defer(ephemeral=True)
|
|
result = await asyncio.to_thread(fast_dispatch, "doctor", [])
|
|
await interaction.followup.send(result, ephemeral=True)
|
|
|
|
@tree.command(name="heartbeat", description="Run heartbeat health checks")
|
|
async def heartbeat_cmd(interaction: discord.Interaction) -> None:
|
|
from src.heartbeat import run_heartbeat
|
|
await interaction.response.defer(ephemeral=True)
|
|
try:
|
|
result = await asyncio.to_thread(run_heartbeat)
|
|
await interaction.followup.send(result, ephemeral=True)
|
|
except Exception as e:
|
|
await interaction.followup.send(
|
|
f"Heartbeat error: {e}", ephemeral=True
|
|
)
|
|
|
|
@tree.command(name="search", description="Search Echo's memory")
|
|
@app_commands.describe(query="What to search for")
|
|
async def search_cmd(
|
|
interaction: discord.Interaction, query: str
|
|
) -> None:
|
|
await interaction.response.defer()
|
|
try:
|
|
from src.memory_search import search
|
|
|
|
results = await asyncio.to_thread(search, query)
|
|
if not results:
|
|
await interaction.followup.send(
|
|
"No results found (index may be empty — run `echo memory reindex`)."
|
|
)
|
|
return
|
|
|
|
lines = [f"**Search results for:** {query}\n"]
|
|
for i, r in enumerate(results, 1):
|
|
score = r["score"]
|
|
preview = r["chunk"][:150]
|
|
if len(r["chunk"]) > 150:
|
|
preview += "..."
|
|
lines.append(
|
|
f"**{i}.** `{r['file']}` (score: {score:.3f})\n{preview}\n"
|
|
)
|
|
text = "\n".join(lines)
|
|
if len(text) > 1900:
|
|
text = text[:1900] + "\n..."
|
|
await interaction.followup.send(text)
|
|
except ConnectionError as e:
|
|
await interaction.followup.send(f"Search error: {e}")
|
|
except Exception as e:
|
|
logger.exception("Search command failed")
|
|
await interaction.followup.send(f"Search error: {e}")
|
|
|
|
@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
|
|
)
|
|
|
|
@tree.command(name="clear", description="Clear the session for this channel")
|
|
async def clear(interaction: discord.Interaction) -> None:
|
|
channel_id = str(interaction.channel_id)
|
|
default_model = config.get("bot.default_model", "sonnet")
|
|
removed = clear_session(channel_id)
|
|
if removed:
|
|
await interaction.response.send_message(
|
|
f"Session cleared. Model reset to {default_model}.",
|
|
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:
|
|
from datetime import datetime, timezone
|
|
import subprocess
|
|
|
|
channel_id = str(interaction.channel_id)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Version info
|
|
try:
|
|
commit = subprocess.run(
|
|
["git", "log", "--format=%h", "-1"],
|
|
capture_output=True, text=True, cwd=str(PROJECT_ROOT),
|
|
).stdout.strip() or "?"
|
|
except Exception:
|
|
commit = "?"
|
|
|
|
# Latency
|
|
try:
|
|
lat = round(client.latency * 1000)
|
|
except (ValueError, TypeError):
|
|
lat = 0
|
|
|
|
# Uptime
|
|
uptime = ""
|
|
if hasattr(client, "_ready_at"):
|
|
elapsed = now - client._ready_at
|
|
secs = int(elapsed.total_seconds())
|
|
if secs < 60:
|
|
uptime = f"{secs}s"
|
|
elif secs < 3600:
|
|
uptime = f"{secs // 60}m"
|
|
else:
|
|
uptime = f"{secs // 3600}h {(secs % 3600) // 60}m"
|
|
|
|
# Channel count
|
|
channels_count = len(config.get("channels", {}))
|
|
|
|
# Session info
|
|
session = get_active_session(channel_id)
|
|
if session:
|
|
sid = session.get("session_id", "?")[:8]
|
|
model = session.get("model", "?")
|
|
count = session.get("message_count", 0)
|
|
created = session.get("created_at", "")
|
|
last_msg = session.get("last_message_at", "")
|
|
|
|
age = ""
|
|
if created:
|
|
try:
|
|
el = now - datetime.fromisoformat(created)
|
|
m = int(el.total_seconds() // 60)
|
|
age = f"{m}m" if m < 60 else f"{m // 60}h {m % 60}m"
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
updated = ""
|
|
if last_msg:
|
|
try:
|
|
el = now - datetime.fromisoformat(last_msg)
|
|
s = int(el.total_seconds())
|
|
if s < 60:
|
|
updated = "just now"
|
|
elif s < 3600:
|
|
updated = f"{s // 60}m ago"
|
|
else:
|
|
updated = f"{s // 3600}h ago"
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Token usage
|
|
in_tok = session.get("total_input_tokens", 0)
|
|
out_tok = session.get("total_output_tokens", 0)
|
|
cost = session.get("total_cost_usd", 0)
|
|
|
|
def _fmt_tokens(n):
|
|
if n >= 1_000_000:
|
|
return f"{n / 1_000_000:.1f}M"
|
|
if n >= 1_000:
|
|
return f"{n / 1_000:.1f}k"
|
|
return str(n)
|
|
|
|
tokens_line = f"Tokens: {_fmt_tokens(in_tok)} in / {_fmt_tokens(out_tok)} out"
|
|
if cost > 0:
|
|
tokens_line += f" | ${cost:.4f}"
|
|
|
|
# Context window usage
|
|
ctx = session.get("context_tokens", 0)
|
|
max_ctx = 200_000
|
|
pct = round(ctx / max_ctx * 100) if ctx else 0
|
|
context_line = f"Context: {_fmt_tokens(ctx)}/{_fmt_tokens(max_ctx)} ({pct}%)"
|
|
|
|
session_line = f"Session: `{sid}` | {count} msgs | {age}" + (f" | updated {updated}" if updated else "")
|
|
else:
|
|
model = config.get("bot", {}).get("default_model", "?")
|
|
session_line = "No active session"
|
|
tokens_line = ""
|
|
context_line = ""
|
|
|
|
lines = [
|
|
f"Echo Core ({commit})",
|
|
f"Model: {model} | Latency: {lat}ms",
|
|
f"Channels: {channels_count} | Uptime: {uptime}",
|
|
tokens_line,
|
|
context_line,
|
|
session_line,
|
|
]
|
|
text = "\n".join(l for l in lines if l)
|
|
await interaction.response.send_message(text, ephemeral=True)
|
|
|
|
@tree.command(name="model", description="View or change the AI model")
|
|
@app_commands.describe(choice="Model to switch to")
|
|
@app_commands.choices(choice=[
|
|
app_commands.Choice(name="opus", value="opus"),
|
|
app_commands.Choice(name="sonnet", value="sonnet"),
|
|
app_commands.Choice(name="haiku", value="haiku"),
|
|
])
|
|
async def model_cmd(
|
|
interaction: discord.Interaction,
|
|
choice: app_commands.Choice[str] | None = None,
|
|
) -> None:
|
|
channel_id = str(interaction.channel_id)
|
|
if choice is None:
|
|
# Show current model and available models
|
|
session = get_active_session(channel_id)
|
|
if session:
|
|
current = session.get("model", "unknown")
|
|
else:
|
|
current = config.get("bot.default_model", "sonnet")
|
|
available = ", ".join(sorted(VALID_MODELS))
|
|
await interaction.response.send_message(
|
|
f"**Current model:** {current}\n"
|
|
f"**Available:** {available}",
|
|
ephemeral=True,
|
|
)
|
|
else:
|
|
model = choice.value
|
|
session = get_active_session(channel_id)
|
|
if session:
|
|
set_session_model(channel_id, model)
|
|
else:
|
|
# No session yet — pre-set in active.json so next message uses it
|
|
from src.claude_session import _load_sessions, _save_sessions
|
|
from datetime import datetime, timezone
|
|
sessions = _load_sessions()
|
|
sessions[channel_id] = {
|
|
"session_id": "",
|
|
"model": model,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"last_message_at": datetime.now(timezone.utc).isoformat(),
|
|
"message_count": 0,
|
|
}
|
|
_save_sessions(sessions)
|
|
await interaction.response.send_message(
|
|
f"Model changed to **{model}**.", ephemeral=True
|
|
)
|
|
|
|
@tree.command(name="restart", description="Restart the bot process")
|
|
async def restart(interaction: discord.Interaction) -> None:
|
|
if not is_owner(str(interaction.user.id)):
|
|
_security_log.warning("Unauthorized owner command /restart by user=%s (%s)", interaction.user.id, interaction.user)
|
|
await interaction.response.send_message(
|
|
"Owner only.", ephemeral=True
|
|
)
|
|
return
|
|
pid_file = PROJECT_ROOT / "echo-core.pid"
|
|
if not pid_file.exists():
|
|
await interaction.response.send_message(
|
|
"No PID file found (echo-core.pid).", ephemeral=True
|
|
)
|
|
return
|
|
try:
|
|
pid = int(pid_file.read_text().strip())
|
|
os.kill(pid, signal.SIGTERM)
|
|
await interaction.response.send_message(
|
|
"Restarting...", ephemeral=True
|
|
)
|
|
except ProcessLookupError:
|
|
await interaction.response.send_message(
|
|
f"Process {pid} not found.", ephemeral=True
|
|
)
|
|
except ValueError:
|
|
await interaction.response.send_message(
|
|
"Invalid PID file content.", ephemeral=True
|
|
)
|
|
|
|
@tree.command(name="logs", description="Show recent log lines")
|
|
@app_commands.describe(n="Number of lines to show (default 10)")
|
|
async def logs_cmd(
|
|
interaction: discord.Interaction, n: int = 10
|
|
) -> None:
|
|
log_path = PROJECT_ROOT / "logs" / "echo-core.log"
|
|
if not log_path.exists():
|
|
await interaction.response.send_message(
|
|
"No log file found.", ephemeral=True
|
|
)
|
|
return
|
|
try:
|
|
all_lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
tail = all_lines[-n:] if len(all_lines) >= n else all_lines
|
|
text = "\n".join(tail)
|
|
# Truncate to fit Discord message limit (2000 - code block overhead)
|
|
if len(text) > 1900:
|
|
text = text[-1900:]
|
|
await interaction.response.send_message(
|
|
f"```\n{text}\n```", ephemeral=True
|
|
)
|
|
except Exception as e:
|
|
await interaction.response.send_message(
|
|
f"Error reading logs: {e}", ephemeral=True
|
|
)
|
|
|
|
# --- Ralph commands (autonomous project execution) ---
|
|
|
|
async def _autocomplete_by_status(
|
|
interaction: discord.Interaction, current: str, statuses: tuple[str, ...]
|
|
) -> list[app_commands.Choice[str]]:
|
|
try:
|
|
data = _load_approved_tasks()
|
|
except Exception:
|
|
return []
|
|
current_low = (current or "").lower()
|
|
choices: list[app_commands.Choice[str]] = []
|
|
for p in data.get("projects", []):
|
|
if p.get("status") not in statuses:
|
|
continue
|
|
name = p.get("name", "")
|
|
if current_low and current_low not in name.lower():
|
|
continue
|
|
desc = (p.get("description") or "").strip()
|
|
label = f"{name} — {desc}"[:100] if desc else name
|
|
choices.append(app_commands.Choice(name=label, value=name))
|
|
if len(choices) >= 25:
|
|
break
|
|
return choices
|
|
|
|
async def _ralph_autocomplete_pending(
|
|
interaction: discord.Interaction, current: str
|
|
) -> list[app_commands.Choice[str]]:
|
|
return await _autocomplete_by_status(interaction, current, ("pending",))
|
|
|
|
async def _ralph_autocomplete_running(
|
|
interaction: discord.Interaction, current: str
|
|
) -> list[app_commands.Choice[str]]:
|
|
return await _autocomplete_by_status(interaction, current, ("running", "approved"))
|
|
|
|
@tree.command(name="p", description="Propose new Ralph project")
|
|
@app_commands.describe(slug="Project slug (e.g. game-library)", description="Short description of what to do")
|
|
async def ralph_p(
|
|
interaction: discord.Interaction, slug: str, description: str
|
|
) -> None:
|
|
await interaction.response.send_message(_ralph_propose(slug, description))
|
|
|
|
@tree.command(name="a", description="Approve Ralph project for tonight (no slug = list pending)")
|
|
@app_commands.describe(slug="Project slug to approve (leave empty to list pending)")
|
|
@app_commands.autocomplete(slug=_ralph_autocomplete_pending)
|
|
async def ralph_a(
|
|
interaction: discord.Interaction, slug: str | None = None
|
|
) -> None:
|
|
slugs = [slug] if slug else []
|
|
await interaction.response.send_message(_ralph_approve(slugs))
|
|
|
|
@tree.command(name="l", description="List Ralph projects status")
|
|
async def ralph_l(interaction: discord.Interaction) -> None:
|
|
await interaction.response.send_message(_ralph_status())
|
|
|
|
@tree.command(name="k", description="Stop a running Ralph project")
|
|
@app_commands.describe(slug="Project slug to stop")
|
|
@app_commands.autocomplete(slug=_ralph_autocomplete_running)
|
|
async def ralph_k(
|
|
interaction: discord.Interaction, slug: str
|
|
) -> None:
|
|
await interaction.response.send_message(_ralph_stop(slug))
|
|
|
|
# --- Events ---
|
|
|
|
@client.event
|
|
async def on_ready() -> None:
|
|
# Sync to each guild instantly, then global (global can take up to 1h)
|
|
for guild in client.guilds:
|
|
tree.copy_global_to(guild=guild)
|
|
await tree.sync(guild=guild)
|
|
await tree.sync()
|
|
scheduler = getattr(client, "scheduler", None)
|
|
if scheduler is not None:
|
|
await scheduler.start()
|
|
from datetime import datetime, timezone
|
|
client._ready_at = datetime.now(timezone.utc)
|
|
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")
|
|
|
|
# Track how many intermediate messages were sent via callback
|
|
sent_count = 0
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def on_text(text_block: str) -> None:
|
|
"""Send intermediate Claude text blocks to the channel."""
|
|
nonlocal sent_count
|
|
chunks = split_message(text_block)
|
|
for chunk in chunks:
|
|
asyncio.run_coroutine_threadsafe(
|
|
message.channel.send(chunk), loop
|
|
)
|
|
sent_count += 1
|
|
|
|
try:
|
|
async with message.channel.typing():
|
|
response, _is_cmd = await asyncio.to_thread(
|
|
route_message, channel_id, user_id, text,
|
|
on_text=on_text,
|
|
)
|
|
|
|
# Only send the final combined response if no intermediates
|
|
# were delivered (avoids duplicating content).
|
|
if sent_count == 0:
|
|
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 messages from any bot (including self)
|
|
if message.author.bot:
|
|
return
|
|
|
|
# DM handling: only process if sender is admin
|
|
if isinstance(message.channel, discord.DMChannel):
|
|
if not is_admin(str(message.author.id)):
|
|
_security_log.warning(
|
|
"Unauthorized DM from user=%s (%s): %s",
|
|
message.author.id, message.author, message.content[:100],
|
|
)
|
|
return
|
|
logger.info(
|
|
"DM from admin %s: %s", message.author, message.content[:100]
|
|
)
|
|
await _handle_chat(message)
|
|
return
|
|
|
|
# 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],
|
|
)
|
|
await _handle_chat(message)
|
|
|
|
return client
|