Skip to content
Merged
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.6.0] - 2026-03-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,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
3 changes: 3 additions & 0 deletions src/bot/handlers/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ...security.audit import AuditLogger
from ...security.validators import SecurityValidator
from ..utils.html_format import escape_html
from .command import _handle_model_selection

logger = structlog.get_logger()

Expand Down Expand Up @@ -66,6 +67,8 @@ async def handle_callback_query(
"conversation": handle_conversation_callback,
"git": handle_git_callback,
"export": handle_export_callback,
"model": lambda q, p, ctx: _handle_model_selection(q, f"model:{p}", ctx),
"effort": lambda q, p, ctx: _handle_model_selection(q, f"effort:{p}", ctx),
}

handler = handlers.get(action)
Expand Down
164 changes: 162 additions & 2 deletions src/bot/handlers/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Optional

import structlog
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes

from ...claude.facade import ClaudeIntegration
Expand Down Expand Up @@ -174,7 +174,8 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
"• <code>/status</code> - Show session and usage status\n"
"• <code>/export</code> - Export session history\n"
"• <code>/actions</code> - Show context-aware quick actions\n"
"• <code>/git</code> - Git repository information\n\n"
"• <code>/git</code> - Git repository information\n"
"• <code>/model [name]</code> - View or switch Claude model\n\n"
"<b>Session Behavior:</b>\n"
"• Sessions are automatically maintained per project directory\n"
"• Switching directories with <code>/cd</code> resumes the session for that project\n"
Expand Down Expand Up @@ -1232,6 +1233,165 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
logger.error("Error in git_command", error=str(e), user_id=user_id)


# Short CLI aliases passed directly to the Claude CLI, which resolves them to
# the current latest model of each family. No version numbers to maintain here.
# See: https://docs.anthropic.com/en/docs/about-claude/models/overview
_MODEL_FAMILIES = ["opus", "sonnet", "haiku"]

# Effort levels per model family. Haiku has none; "max" is Opus-only.
# Update here if a future model's effort support changes.
_EFFORT_BY_MODEL = {
"opus": ["low", "medium", "high", "max"],
"sonnet": ["low", "medium", "high"],
"haiku": [],
}


def _current_model_label(context: ContextTypes.DEFAULT_TYPE) -> str:
"""Return a human-friendly label for the active model + effort."""
override = context.user_data.get("model_override") # "opus", "sonnet", "haiku", or None
effort = context.user_data.get("effort_override")
if not override:
settings = context.bot_data.get("settings")
server_model = getattr(settings, "claude_model", None) if settings else None
label = f"Default ({server_model or 'CLI default'})"
else:
label = override.capitalize()
return f"{label} | effort={effort}" if effort else label


async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /model command - show model selection keyboard."""
current = _current_model_label(context)

keyboard = [
[
InlineKeyboardButton("Opus", callback_data="model:opus"),
InlineKeyboardButton("Sonnet", callback_data="model:sonnet"),
InlineKeyboardButton("Haiku", callback_data="model:haiku"),
],
[InlineKeyboardButton("Reset to default", callback_data="model:default")],
]

await update.message.reply_text(
f"🤖 <b>Current:</b> {escape_html(current)}\n\n"
"Choose a model:\n"
"<i>⚠️ Switching will start a new session.</i>",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(keyboard),
)


async def _handle_model_selection(
query: CallbackQuery,
data: str,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
"""Shared logic for model/effort selection (used by both callback routes)."""
if data.startswith("model:"):
choice = data.split(":", 1)[1]

if choice == "default":
context.user_data.pop("model_override", None)
context.user_data.pop("effort_override", None)
# Note: if PR #165 merges first, change this to context.chat_data
context.user_data["force_new_session"] = True
await query.edit_message_text(
"🤖 Model and effort reset to server defaults.\n"
"<i>Next message starts a fresh session.</i>",
parse_mode="HTML",
)
logger.info("Model override cleared", user_id=query.from_user.id)
return

if choice not in _MODEL_FAMILIES:
await query.edit_message_text("Unknown model.")
return

# Store short CLI alias ("opus"/"sonnet"/"haiku") — the CLI resolves it
# to the current latest model, so no version numbers to maintain.
context.user_data["model_override"] = choice
# Clear stale effort when switching models
context.user_data.pop("effort_override", None)
# Force new session so the model change takes effect immediately
# Note: if PR #165 merges first, change this to context.chat_data
context.user_data["force_new_session"] = True

logger.info(
"Model override set",
user_id=query.from_user.id,
model=choice,
)

# Show effort level selection (if supported by this model)
effort_levels = _EFFORT_BY_MODEL.get(choice, [])
if not effort_levels:
# Model doesn't support effort (e.g. Haiku)
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
return

rows = []
row = []
for level in effort_levels:
row.append(
InlineKeyboardButton(level.capitalize(), callback_data=f"effort:{level}")
)
if len(row) == 2:
rows.append(row)
row = []
if row:
rows.append(row)
rows.append(
[InlineKeyboardButton("Skip (keep current)", callback_data="effort:skip")]
)

await query.edit_message_text(
f"🤖 Model set to <b>{escape_html(choice.capitalize())}</b>.\n\n"
"Choose effort level:",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(rows),
)

elif data.startswith("effort:"):
level = data.split(":", 1)[1]

if level == "skip":
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
return

all_effort_levels = {"low", "medium", "high", "max"}
if level in all_effort_levels:
context.user_data["effort_override"] = level
current = _current_model_label(context)
await query.edit_message_text(
f"🤖 <b>{escape_html(current)}</b> — ready.\n"
"<i>New session will start with your next message.</i>",
parse_mode="HTML",
)
logger.info(
"Effort override set",
user_id=query.from_user.id,
effort=level,
)


async def model_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle model and effort selection callbacks (agentic mode route)."""
query = update.callback_query
await query.answer()
await _handle_model_selection(query, query.data, context)


async def restart_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /restart command - gracefully restart the bot process.

Expand Down
8 changes: 8 additions & 0 deletions src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ async def stream_handler(update_obj):
session_id=session_id,
on_stream=stream_handler,
force_new=force_new,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# New session created successfully — clear the one-shot flag
Expand Down Expand Up @@ -818,6 +820,8 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# Update session ID
Expand Down Expand Up @@ -945,6 +949,8 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

# Update session ID
Expand Down Expand Up @@ -1073,6 +1079,8 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
model_override=context.user_data.get("model_override"),
effort_override=context.user_data.get("effort_override"),
)

context.user_data["claude_session_id"] = claude_response.session_id
Expand Down
Loading