stage-8: cron scheduler with APScheduler

Scheduler class, cron/jobs.json, Discord /cron commands, CLI cron subcommand, job lifecycle management. 88 new tests (281 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 16:12:56 +00:00
parent 09d3de003a
commit 24a4d87f8c
8 changed files with 1640 additions and 1 deletions

View File

@@ -52,6 +52,15 @@ def is_registered_channel(channel_id: str) -> bool:
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 ---
@@ -114,6 +123,14 @@ def create_bot(config: Config) -> discord.Client:
"`/model <choice>` — Change model for this channel's session",
"`/logs [n]` — Show last N log lines (default 10)",
"`/restart` — Restart the bot process (owner only)",
"",
"**Cron Jobs**",
"`/cron list` — List all scheduled jobs",
"`/cron run <name>` — Force-run a job now",
"`/cron add <name> <expr> [model]` — Create a scheduled job (admin)",
"`/cron remove <name>` — Remove a job (admin)",
"`/cron enable <name>` — Enable a job (admin)",
"`/cron disable <name>` — Disable a job (admin)",
]
await interaction.response.send_message(
"\n".join(lines), ephemeral=True
@@ -182,6 +199,207 @@ def create_bot(config: Config) -> discord.Client:
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)):
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)):
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)):
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)):
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)
@tree.command(name="channels", description="List registered channels")
async def channels(interaction: discord.Interaction) -> None:
ch_map = config.get("channels", {})
@@ -340,6 +558,9 @@ def create_bot(config: Config) -> discord.Client:
@client.event
async def on_ready() -> None:
await tree.sync()
scheduler = getattr(client, "scheduler", None)
if scheduler is not None:
await scheduler.start()
logger.info("Echo Core online as %s", client.user)
async def _handle_chat(message: discord.Message) -> None: