Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **`/schedule` command**: Create, list, pause, resume, and remove scheduled jobs directly from Telegram. Auto-populates chat, directory, and user from context. Requires `ENABLE_SCHEDULER=true` (#150)

## [1.5.0] - 2026-03-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ All datetimes use timezone-aware UTC: `datetime.now(UTC)` (not `datetime.utcnow(

### Agentic mode

Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/repo`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. To add a new command:
Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/repo`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. If `ENABLE_SCHEDULER=true`: `/schedule`. To add a new command:

1. Add handler function in `src/bot/orchestrator.py`
2. Register in `MessageOrchestrator._register_agentic_handlers()`
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ The bot supports two interaction modes:
The default conversational mode. Just talk to Claude naturally -- no special commands required.

**Commands:** `/start`, `/new`, `/status`, `/verbose`, `/repo`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads` | If `ENABLE_SCHEDULER=true`: `/schedule`

```
You: What files are in this project?
Expand Down Expand Up @@ -157,8 +157,8 @@ Use `/repo` to list cloned repos in your workspace, or `/repo <name>` to switch

Set `AGENTIC_MODE=false` to enable the full 13-command terminal-like interface with directory navigation, inline keyboards, quick actions, git integration, and session export.

**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`
**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads` | If `ENABLE_SCHEDULER=true`: `/schedule`

```
You: /cd my-web-app
Expand All @@ -176,7 +176,7 @@ Bot: [Run Tests] [Install Deps] [Format Code] [Run Linter]
Beyond direct chat, the bot can respond to external triggers:

- **Webhooks** -- Receive GitHub events (push, PR, issues) and route them through Claude for automated summaries or code review
- **Scheduler** -- Run recurring Claude tasks on a cron schedule (e.g., daily code health checks)
- **Scheduler** -- Run recurring Claude tasks on a cron schedule (e.g., daily code health checks). Manage jobs directly from Telegram with `/schedule`
- **Notifications** -- Deliver agent responses to configured Telegram chats

Enable with `ENABLE_API_SERVER=true` and `ENABLE_SCHEDULER=true`. See [docs/setup.md](docs/setup.md) for configuration.
Expand Down
18 changes: 17 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,23 @@ ENABLE_SCHEDULER=true
NOTIFICATION_CHAT_IDS=123456789 # Where to deliver results
```

Jobs are managed programmatically and persist in the SQLite database.
Jobs persist in the SQLite database and survive bot restarts. Manage them directly from Telegram with the `/schedule` command:

```
/schedule list # List all jobs (active + paused)
/schedule add <name> <min> <hour> <day> <month> <weekday> <prompt>
/schedule remove <job_id> # Remove a job
/schedule pause <job_id> # Pause without deleting
/schedule resume <job_id> # Resume a paused job
```

Example -- create a job that runs every weekday at 9 AM:

```
/schedule add daily-report 0 9 * * 1-5 Summarize yesterday's git commits
```

The `chat_id`, `working_directory`, and `created_by` fields are auto-populated from your current Telegram context. Results are delivered to the chat where the job was created.

### Voice Message Transcription

Expand Down
200 changes: 200 additions & 0 deletions src/bot/handlers/schedule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Schedule command handler for managing scheduled jobs via Telegram."""

from typing import List

import structlog
from telegram import Update
from telegram.ext import ContextTypes

from ...scheduler.scheduler import JobScheduler
from ..utils.html_format import escape_html

logger = structlog.get_logger()


def _get_scheduler(context: ContextTypes.DEFAULT_TYPE) -> JobScheduler | None:
"""Get the JobScheduler from bot dependencies."""
return context.bot_data.get("scheduler")


async def schedule_command(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Handle /schedule command with subcommands.

Usage:
/schedule list
/schedule add <name> <cron_expr> <prompt>
/schedule remove <job_id>
/schedule pause <job_id>
/schedule resume <job_id>
"""
scheduler = _get_scheduler(context)
if not scheduler:
await update.message.reply_text(
"Scheduler is not enabled. Set ENABLE_SCHEDULER=true to use this command."
)
return

args: List[str] = context.args or []

if not args:
await _show_usage(update)
return

subcommand = args[0].lower()
sub_args = args[1:]

if subcommand == "list":
await _handle_list(update, scheduler)
elif subcommand == "add":
await _handle_add(update, context, scheduler, sub_args)
elif subcommand == "remove":
await _handle_remove(update, scheduler, sub_args)
elif subcommand == "pause":
await _handle_pause(update, scheduler, sub_args)
elif subcommand == "resume":
await _handle_resume(update, scheduler, sub_args)
else:
await _show_usage(update)


async def _show_usage(update: Update) -> None:
"""Show usage information."""
await update.message.reply_html(
"<b>Usage:</b>\n"
"/schedule list\n"
"/schedule add &lt;name&gt; &lt;cron&gt; &lt;prompt&gt;\n"
"/schedule remove &lt;job_id&gt;\n"
"/schedule pause &lt;job_id&gt;\n"
"/schedule resume &lt;job_id&gt;\n\n"
"<b>Cron format:</b> min hour day month weekday\n"
"Example: <code>/schedule add daily-report 0 9 * * * Check status</code>"
)


async def _handle_list(update: Update, scheduler: JobScheduler) -> None:
"""List all scheduled jobs."""
jobs = await scheduler.list_jobs(include_paused=True)

if not jobs:
await update.message.reply_text("No scheduled jobs.")
return

lines = ["<b>Scheduled Jobs:</b>\n"]
for job in jobs:
status = "active" if job.get("is_active") else "paused"
name = escape_html(job.get("job_name", "?"))
cron = escape_html(job.get("cron_expression", "?"))
job_id = escape_html(str(job.get("job_id", "?")))
lines.append(
f"<b>{name}</b> [{status}]\n"
f" Cron: <code>{cron}</code>\n"
f" ID: <code>{job_id}</code>"
)

await update.message.reply_html("\n\n".join(lines))


async def _handle_add(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
scheduler: JobScheduler,
args: List[str],
) -> None:
"""Add a new scheduled job.

Expected args: <name> <min> <hour> <day> <month> <weekday> <prompt...>
The 5 cron fields are joined into a single cron expression.
"""
# Need at least: name + 5 cron fields + 1 prompt word = 7
if len(args) < 7:
await update.message.reply_html(
"<b>Usage:</b> /schedule add &lt;name&gt; "
"&lt;min&gt; &lt;hour&gt; &lt;day&gt; &lt;month&gt; &lt;weekday&gt; "
"&lt;prompt&gt;\n\n"
"Example: <code>/schedule add daily-report 0 9 * * * Check status</code>"
)
return

job_name = args[0]
cron_expression = " ".join(args[1:6])
prompt = " ".join(args[6:])

# Auto-populate fields from context
chat_id = update.effective_chat.id
user_id = update.effective_user.id
settings = context.bot_data.get("settings")
working_dir = context.user_data.get(
"current_directory",
settings.approved_directory if settings else None,
)

try:
job_id = await scheduler.add_job(
job_name=job_name,
cron_expression=cron_expression,
prompt=prompt,
target_chat_ids=[chat_id],
working_directory=working_dir,
created_by=user_id,
)
await update.message.reply_html(
f"Job <b>{escape_html(job_name)}</b> created.\n"
f"ID: <code>{escape_html(job_id)}</code>\n"
f"Cron: <code>{escape_html(cron_expression)}</code>"
)
except Exception as e:
logger.exception("Failed to add scheduled job", error=str(e))
await update.message.reply_text(f"Failed to create job: {e}")


async def _handle_remove(
update: Update, scheduler: JobScheduler, args: List[str]
) -> None:
"""Remove a scheduled job."""
if not args:
await update.message.reply_text("Usage: /schedule remove <job_id>")
return

job_id = args[0]
await scheduler.remove_job(job_id)
await update.message.reply_html(
f"Job <code>{escape_html(job_id)}</code> removed."
)


async def _handle_pause(
update: Update, scheduler: JobScheduler, args: List[str]
) -> None:
"""Pause a scheduled job."""
if not args:
await update.message.reply_text("Usage: /schedule pause <job_id>")
return

job_id = args[0]
success = await scheduler.pause_job(job_id)
if success:
await update.message.reply_html(
f"Job <code>{escape_html(job_id)}</code> paused."
)
else:
await update.message.reply_text(f"Job '{job_id}' not found.")


async def _handle_resume(
update: Update, scheduler: JobScheduler, args: List[str]
) -> None:
"""Resume a paused job."""
if not args:
await update.message.reply_text("Usage: /schedule resume <job_id>")
return

job_id = args[0]
success = await scheduler.resume_job(job_id)
if success:
await update.message.reply_html(
f"Job <code>{escape_html(job_id)}</code> resumed."
)
else:
await update.message.reply_text(f"Job '{job_id}' not found or failed to resume.")
12 changes: 12 additions & 0 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ def _register_agentic_handlers(self, app: Application) -> None:
]
if self.settings.enable_project_threads:
handlers.append(("sync_threads", command.sync_threads))
if self.settings.enable_scheduler:
from .handlers import schedule

handlers.append(("schedule", schedule.schedule_command))

for cmd, handler in handlers:
app.add_handler(CommandHandler(cmd, self._inject_deps(handler)))
Expand Down Expand Up @@ -376,6 +380,10 @@ def _register_classic_handlers(self, app: Application) -> None:
]
if self.settings.enable_project_threads:
handlers.append(("sync_threads", command.sync_threads))
if self.settings.enable_scheduler:
from .handlers import schedule

handlers.append(("schedule", schedule.schedule_command))

for cmd, handler in handlers:
app.add_handler(CommandHandler(cmd, self._inject_deps(handler)))
Expand Down Expand Up @@ -420,6 +428,8 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
]
if self.settings.enable_project_threads:
commands.append(BotCommand("sync_threads", "Sync project topics"))
if self.settings.enable_scheduler:
commands.append(BotCommand("schedule", "Manage scheduled jobs"))
return commands
else:
commands = [
Expand All @@ -440,6 +450,8 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
]
if self.settings.enable_project_threads:
commands.append(BotCommand("sync_threads", "Sync project topics"))
if self.settings.enable_scheduler:
commands.append(BotCommand("schedule", "Manage scheduled jobs"))
return commands

# --- Agentic handlers ---
Expand Down
2 changes: 2 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async def create_application(config: Settings) -> Dict[str, Any]:
"event_bus": event_bus,
"project_registry": None,
"project_threads_manager": None,
"scheduler": None,
}

bot = ClaudeCodeBot(config, dependencies)
Expand Down Expand Up @@ -314,6 +315,7 @@ def signal_handler(signum: int, frame: Any) -> None:
default_working_directory=config.approved_directory,
)
await scheduler.start()
bot.deps["scheduler"] = scheduler
logger.info("Job scheduler enabled")

# Shutdown task
Expand Down
Loading