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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user