Skip to content
Open
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]

### Changed
- **Agentic mode no longer imports from the classic handlers**: `src/bot/orchestrator.py` pulled `_format_error_message` and `_update_working_directory_from_claude_response` out of `src/bot/handlers/message.py` at seven call sites, and registered `restart_command` and `sync_threads` from `src/bot/handlers/command.py`, which is what made classic mode undeletable. The two helpers now live in `src/bot/utils/error_messages.py` and `src/bot/utils/working_directory.py`; the two commands, which both modes register, live in `src/bot/commands.py`. Classic mode imports all four from the new homes, so nothing changes for either mode. The only `from .handlers` import left in the orchestrator is the classic registration inside `_register_classic_handlers`, and a new test walks the orchestrator's AST so another one cannot slip back in. This is the groundwork for classic-mode removal, roadmap item 4.1 in `docs/ROADMAP-v2.md`

### Fixed
- **The Claude review workflow posts one review per pull request instead of one per push**: the job reruns on every `synchronize` and posted a fresh comment each time, so #236 collected nine full reviews in four and a half hours, each restating what the last had already settled. `use_sticky_comment` was set but does nothing here — it only applies to the action's tag mode, and this workflow supplies `prompt`, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. The prompt now edits its own previous comment with `gh pr comment --edit-last --create-if-none`, so the pull request carries one review at the current head and GitHub keeps the superseded text in the comment's edit history. The inert input is removed rather than left to look load-bearing
- **The review reports only what should block the merge**: most of the length of those nine reviews was praise, an account of what had been checked, and cosmetic nits ("after 1 turns"), and every nit drew another push, which triggered another review — that loop, not the reviewing, was the spam. The prompt now names what qualifies (a security regression, a bug, an untested behaviour change, a missing setting or CHANGELOG entry) and rules out the rest, including anything `black`, `isort` or `flake8` already gates, and findings that cannot be confirmed from the code. It also reads its own previous review first so it does not repeat itself, but what settles a finding is the code at the current head rather than a reply claiming a fix: the replies are contributor-authored and untrusted like the rest of the pull request, so an earlier finding is re-checked against the diff and raised again unchanged when the code does not carry the claimed fix
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ context.bot_data["security_validator"]
- `src/bot/middleware/` -- Auth, rate limit, security input validation
- `src/bot/features/` -- Git integration, file handling, quick actions, session export
- `src/bot/orchestrator.py` -- MessageOrchestrator: routes to agentic or classic handlers, project-topic routing
- `src/bot/commands.py` -- commands both modes register (`/restart`, `/sync_threads`); not classic-mode code
- `src/bot/utils/` -- formatting, HTML escaping, image extraction, error rendering (`error_messages.py`), working-directory tracking (`working_directory.py`), shared by both modes
- `src/claude/` -- Claude integration facade, SDK/CLI managers, session management, tool monitoring
- `src/projects/` -- Multi-project support: `registry.py` (YAML project config), `thread_manager.py` (Telegram topic sync/routing)
- `src/storage/` -- SQLite via aiosqlite, repository pattern (users, sessions, messages, tool_usage, audit_log, cost_tracking, project_threads)
Expand Down Expand Up @@ -139,6 +141,8 @@ Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/repo`. If `ENA

### Classic mode

Classic mode is scheduled for removal in 2.0 (roadmap item 4.1). Agentic code must not import from `src/bot/handlers/`: anything both modes need lives in `src/bot/commands.py` or `src/bot/utils/`, and `tests/unit/test_bot/test_agentic_imports.py` fails if `orchestrator.py` gains another `from .handlers` import.

1. Add handler function in `src/bot/handlers/command.py`
2. Register in `MessageOrchestrator._register_classic_handlers()`
3. Add to `MessageOrchestrator.get_bot_commands()` for Telegram's command menu
Expand Down
155 changes: 155 additions & 0 deletions src/bot/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Commands registered in both agentic and classic mode.

``/restart`` and ``/sync_threads`` are not classic-mode features: the agentic
orchestrator registers them too. They live here, outside
``src/bot/handlers/``, so that agentic mode does not import from the classic
handlers package.
"""

import os
import signal

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

from ..config.settings import Settings
from ..projects import PrivateTopicsUnavailableError, load_project_registry
from ..security.audit import AuditLogger
from .utils.html_format import escape_html

logger = structlog.get_logger()


def _is_private_chat(update: Update) -> bool:
"""Return True when update is from a private chat."""
chat = update.effective_chat
return bool(chat and getattr(chat, "type", "") == "private")


async def sync_threads(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Synchronize project topics in the configured forum chat."""
settings: Settings = context.bot_data["settings"]
audit_logger: AuditLogger = context.bot_data.get("audit_logger")
user_id = update.effective_user.id

if not settings.enable_project_threads:
await update.message.reply_text(
"ℹ️ <b>Project thread mode is disabled.</b>", parse_mode="HTML"
)
return

manager = context.bot_data.get("project_threads_manager")
if not manager:
await update.message.reply_text(
"❌ <b>Project thread manager not initialized.</b>", parse_mode="HTML"
)
return

status_msg = await update.message.reply_text(
"🔄 <b>Syncing project topics...</b>", parse_mode="HTML"
)

if settings.project_threads_mode == "private":
if not _is_private_chat(update):
await status_msg.edit_text(
"❌ <b>Private Thread Mode</b>\n\n"
"Run <code>/sync_threads</code> in your private chat with the bot.",
parse_mode="HTML",
)
return
target_chat_id = update.effective_chat.id
else:
if settings.project_threads_chat_id is None:
await status_msg.edit_text(
"❌ <b>Group Thread Mode Misconfigured</b>\n\n"
"Set <code>PROJECT_THREADS_CHAT_ID</code> first.",
parse_mode="HTML",
)
return
if (
not update.effective_chat
or update.effective_chat.id != settings.project_threads_chat_id
):
await status_msg.edit_text(
"❌ <b>Group Thread Mode</b>\n\n"
"Run <code>/sync_threads</code> in the configured project threads group.",
parse_mode="HTML",
)
return
target_chat_id = settings.project_threads_chat_id

try:
if not settings.projects_config_path:
await status_msg.edit_text(
"❌ <b>Project thread mode is misconfigured</b>\n\n"
"Set <code>PROJECTS_CONFIG_PATH</code> to a valid YAML file.",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)
return

registry = load_project_registry(
config_path=settings.projects_config_path,
approved_directory=settings.approved_directory,
)
manager.registry = registry
context.bot_data["project_registry"] = registry

result = await manager.sync_topics(context.bot, chat_id=target_chat_id)
await status_msg.edit_text(
"✅ <b>Project topic sync complete</b>\n\n"
f"• Created: <b>{result.created}</b>\n"
f"• Reused: <b>{result.reused}</b>\n"
f"• Renamed: <b>{result.renamed}</b>\n"
f"• Reopened: <b>{result.reopened}</b>\n"
f"• Closed: <b>{result.closed}</b>\n"
f"• Deactivated: <b>{result.deactivated}</b>\n"
f"• Failed: <b>{result.failed}</b>",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], True)
except PrivateTopicsUnavailableError:
await status_msg.edit_text(
manager.private_topics_unavailable_message(),
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)
except Exception as e:
await status_msg.edit_text(
f"❌ <b>Project topic sync failed</b>\n\n{escape_html(str(e))}",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)


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

Sends a confirmation message then triggers SIGTERM so systemd
(or any process manager with restart-on-exit) brings the bot back up.

Auth: protected by the auth middleware (group -2) which raises
``ApplicationHandlerStop`` for unauthenticated users before any
handler in group 10 runs. No per-handler check is needed.
"""
audit_logger: AuditLogger = context.bot_data.get("audit_logger")
user_id = update.effective_user.id

await update.message.reply_text(
"🔄 <b>Restarting bot…</b>\n\nBack shortly.",
parse_mode="HTML",
)

if audit_logger:
await audit_logger.log_command(user_id, "restart", [], True)

logger.info("Restart requested via /restart command", user_id=user_id)

# SIGTERM triggers the existing graceful-shutdown handler in main.py;
# systemd Restart=always will bring the process back up.
os.kill(os.getpid(), signal.SIGTERM)
139 changes: 2 additions & 137 deletions src/bot/handlers/command.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Command handlers for bot operations."""

import os
import signal
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
Expand All @@ -12,10 +10,11 @@

from ...claude.facade import ClaudeIntegration
from ...config.settings import Settings
from ...projects import PrivateTopicsUnavailableError, load_project_registry
from ...projects import PrivateTopicsUnavailableError
from ...security.audit import AuditLogger
from ...security.validators import SecurityValidator
from ...storage.models import SessionModel
from ..commands import _is_private_chat
from ..utils.html_format import escape_html

logger = structlog.get_logger()
Expand All @@ -42,12 +41,6 @@ def _get_thread_project_root(
return Path(thread_context["project_root"]).resolve()


def _is_private_chat(update: Update) -> bool:
"""Return True when update is from a private chat."""
chat = update.effective_chat
return bool(chat and getattr(chat, "type", "") == "private")


async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
user = update.effective_user
Expand Down Expand Up @@ -205,106 +198,6 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
await update.message.reply_text(help_text, parse_mode="HTML")


async def sync_threads(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Synchronize project topics in the configured forum chat."""
settings: Settings = context.bot_data["settings"]
audit_logger: AuditLogger = context.bot_data.get("audit_logger")
user_id = update.effective_user.id

if not settings.enable_project_threads:
await update.message.reply_text(
"ℹ️ <b>Project thread mode is disabled.</b>", parse_mode="HTML"
)
return

manager = context.bot_data.get("project_threads_manager")
if not manager:
await update.message.reply_text(
"❌ <b>Project thread manager not initialized.</b>", parse_mode="HTML"
)
return

status_msg = await update.message.reply_text(
"🔄 <b>Syncing project topics...</b>", parse_mode="HTML"
)

if settings.project_threads_mode == "private":
if not _is_private_chat(update):
await status_msg.edit_text(
"❌ <b>Private Thread Mode</b>\n\n"
"Run <code>/sync_threads</code> in your private chat with the bot.",
parse_mode="HTML",
)
return
target_chat_id = update.effective_chat.id
else:
if settings.project_threads_chat_id is None:
await status_msg.edit_text(
"❌ <b>Group Thread Mode Misconfigured</b>\n\n"
"Set <code>PROJECT_THREADS_CHAT_ID</code> first.",
parse_mode="HTML",
)
return
if (
not update.effective_chat
or update.effective_chat.id != settings.project_threads_chat_id
):
await status_msg.edit_text(
"❌ <b>Group Thread Mode</b>\n\n"
"Run <code>/sync_threads</code> in the configured project threads group.",
parse_mode="HTML",
)
return
target_chat_id = settings.project_threads_chat_id

try:
if not settings.projects_config_path:
await status_msg.edit_text(
"❌ <b>Project thread mode is misconfigured</b>\n\n"
"Set <code>PROJECTS_CONFIG_PATH</code> to a valid YAML file.",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)
return

registry = load_project_registry(
config_path=settings.projects_config_path,
approved_directory=settings.approved_directory,
)
manager.registry = registry
context.bot_data["project_registry"] = registry

result = await manager.sync_topics(context.bot, chat_id=target_chat_id)
await status_msg.edit_text(
"✅ <b>Project topic sync complete</b>\n\n"
f"• Created: <b>{result.created}</b>\n"
f"• Reused: <b>{result.reused}</b>\n"
f"• Renamed: <b>{result.renamed}</b>\n"
f"• Reopened: <b>{result.reopened}</b>\n"
f"• Closed: <b>{result.closed}</b>\n"
f"• Deactivated: <b>{result.deactivated}</b>\n"
f"• Failed: <b>{result.failed}</b>",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], True)
except PrivateTopicsUnavailableError:
await status_msg.edit_text(
manager.private_topics_unavailable_message(),
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)
except Exception as e:
await status_msg.edit_text(
f"❌ <b>Project topic sync failed</b>\n\n{escape_html(str(e))}",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(user_id, "sync_threads", [], False)


async def new_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /new command - explicitly starts a fresh session, clearing previous context."""
settings: Settings = context.bot_data["settings"]
Expand Down Expand Up @@ -1232,34 +1125,6 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
logger.error("Error in git_command", error=str(e), user_id=user_id)


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

Sends a confirmation message then triggers SIGTERM so systemd
(or any process manager with restart-on-exit) brings the bot back up.

Auth: protected by the auth middleware (group -2) which raises
``ApplicationHandlerStop`` for unauthenticated users before any
handler in group 10 runs. No per-handler check is needed.
"""
audit_logger: AuditLogger = context.bot_data.get("audit_logger")
user_id = update.effective_user.id

await update.message.reply_text(
"🔄 <b>Restarting bot…</b>\n\nBack shortly.",
parse_mode="HTML",
)

if audit_logger:
await audit_logger.log_command(user_id, "restart", [], True)

logger.info("Restart requested via /restart command", user_id=user_id)

# SIGTERM triggers the existing graceful-shutdown handler in main.py;
# systemd Restart=always will bring the process back up.
os.kill(os.getpid(), signal.SIGTERM)


def _format_file_size(size: int) -> str:
"""Format file size in human-readable format."""
for unit in ["B", "KB", "MB", "GB"]:
Expand Down
Loading
Loading