free-claude-code/messaging/commands.py
2026-03-01 04:28:22 -08:00

283 lines
10 KiB
Python

"""Command handlers for messaging platform commands (/stop, /stats, /clear).
Extracted from ClaudeMessageHandler to keep handler.py focused on
core message processing logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from messaging.handler import ClaudeMessageHandler
from messaging.models import IncomingMessage
async def handle_stop_command(
handler: ClaudeMessageHandler, incoming: IncomingMessage
) -> None:
"""Handle /stop command from messaging platform."""
# Reply-scoped stop: reply "/stop" to stop only that task.
if incoming.is_reply() and incoming.reply_to_message_id:
reply_id = incoming.reply_to_message_id
tree = handler.tree_queue.get_tree_for_node(reply_id)
node_id = handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
if not node_id:
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
handler.format_status(
"", "Stopped.", "Nothing to stop for that message."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
count = await handler.stop_task(node_id)
noun = "request" if count == 1 else "requests"
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
handler.format_status("", "Stopped.", f"Cancelled {count} {noun}."),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
# Global stop: legacy behavior (stop everything)
count = await handler.stop_all_tasks()
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
handler.format_status(
"", "Stopped.", f"Cancelled {count} pending or active requests."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
async def handle_stats_command(
handler: ClaudeMessageHandler, incoming: IncomingMessage
) -> None:
"""Handle /stats command."""
stats = handler.cli_manager.get_stats()
tree_count = handler.tree_queue.get_tree_count()
ctx = handler.get_render_ctx()
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
"📊 "
+ ctx.bold("Stats")
+ "\n"
+ ctx.escape_text(f"• Active CLI: {stats['active_sessions']}")
+ "\n"
+ ctx.escape_text(f"• Message Trees: {tree_count}"),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
async def _delete_message_ids(
handler: ClaudeMessageHandler, chat_id: str, msg_ids: set[str]
) -> None:
"""Best-effort delete messages by ID. Sorts numeric IDs descending."""
if not msg_ids:
return
def _as_int(s: str) -> int | None:
try:
return int(str(s))
except Exception:
return None
numeric: list[tuple[int, str]] = []
non_numeric: list[str] = []
for mid in msg_ids:
n = _as_int(mid)
if n is None:
non_numeric.append(mid)
else:
numeric.append((n, mid))
numeric.sort(reverse=True)
ordered = [mid for _, mid in numeric] + non_numeric
batch_fn = getattr(handler.platform, "queue_delete_messages", None)
if callable(batch_fn):
try:
CHUNK = 100
for i in range(0, len(ordered), CHUNK):
chunk = ordered[i : i + CHUNK]
await batch_fn(chat_id, chunk, fire_and_forget=False)
except Exception as e:
logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
else:
for mid in ordered:
try:
await handler.platform.queue_delete_message(
chat_id, mid, fire_and_forget=False
)
except Exception as e:
logger.debug(f"Delete failed for msg {mid}: {type(e).__name__}: {e}")
async def _handle_clear_branch(
handler: ClaudeMessageHandler,
incoming: IncomingMessage,
branch_root_id: str,
) -> None:
"""
Clear a branch (replied-to node + all descendants).
Order: cancel tasks, delete messages, remove branch, update session store.
"""
tree = handler.tree_queue.get_tree_for_node(branch_root_id)
if not tree:
return
# 1) Cancel branch tasks (no stop_all)
cancelled = await handler.tree_queue.cancel_branch(branch_root_id)
handler.update_cancelled_nodes_ui(cancelled)
# 2) Collect message IDs from branch nodes only
msg_ids: set[str] = set()
branch_ids = tree.get_descendants(branch_root_id)
for nid in branch_ids:
node = tree.get_node(nid)
if node:
if node.incoming.message_id:
msg_ids.add(str(node.incoming.message_id))
if node.status_message_id:
msg_ids.add(str(node.status_message_id))
if incoming.message_id:
msg_ids.add(str(incoming.message_id))
# 3) Delete messages (best-effort)
await _delete_message_ids(handler, incoming.chat_id, msg_ids)
# 4) Remove branch from tree
removed, root_id, removed_entire_tree = await handler.tree_queue.remove_branch(
branch_root_id
)
# 5) Update session store
try:
handler.session_store.remove_node_mappings([n.node_id for n in removed])
if removed_entire_tree:
handler.session_store.remove_tree(root_id)
else:
updated_tree = handler.tree_queue.get_tree(root_id)
if updated_tree:
handler.session_store.save_tree(root_id, updated_tree.to_dict())
except Exception as e:
logger.warning(f"Failed to update session store after branch clear: {e}")
async def handle_clear_command(
handler: ClaudeMessageHandler, incoming: IncomingMessage
) -> None:
"""
Handle /clear command.
Reply-scoped: reply to a message to clear that branch (node + descendants).
Standalone: global clear (stop all, delete all chat messages, reset store).
"""
from messaging.trees import TreeQueueManager
if incoming.is_reply() and incoming.reply_to_message_id:
reply_id = incoming.reply_to_message_id
tree = handler.tree_queue.get_tree_for_node(reply_id)
branch_root_id = (
handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
)
if not branch_root_id:
cancel_fn = getattr(handler.platform, "cancel_pending_voice", None)
if cancel_fn is not None:
cancelled = await cancel_fn(incoming.chat_id, reply_id)
if cancelled is not None:
voice_msg_id, status_msg_id = cancelled
msg_ids_to_del: set[str] = {voice_msg_id, status_msg_id}
if incoming.message_id is not None:
msg_ids_to_del.add(str(incoming.message_id))
await _delete_message_ids(handler, incoming.chat_id, msg_ids_to_del)
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
handler.format_status("🗑", "Cleared.", "Voice note cancelled."),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
msg_id = await handler.platform.queue_send_message(
incoming.chat_id,
handler.format_status(
"🗑", "Cleared.", "Nothing to clear for that message."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
await _handle_clear_branch(handler, incoming, branch_root_id)
return
# Global clear
# 1) Stop tasks first (ensures no more work is running).
await handler.stop_all_tasks()
# 2) Clear chat: best-effort delete messages we can identify.
msg_ids: set[str] = set()
# Add any recorded message IDs for this chat (commands, command replies, etc).
try:
for mid in handler.session_store.get_message_ids_for_chat(
incoming.platform, incoming.chat_id
):
if mid is not None:
msg_ids.add(str(mid))
except Exception as e:
logger.debug(f"Failed to read message log for /clear: {e}")
try:
msg_ids.update(
handler.tree_queue.get_message_ids_for_chat(
incoming.platform, incoming.chat_id
)
)
except Exception as e:
logger.warning(f"Failed to gather messages for /clear: {e}")
# Also delete the command message itself.
if incoming.message_id is not None:
msg_ids.add(str(incoming.message_id))
await _delete_message_ids(handler, incoming.chat_id, msg_ids)
# 3) Clear persistent state and reset in-memory queue/tree state.
try:
handler.session_store.clear_all()
except Exception as e:
logger.warning(f"Failed to clear session store: {e}")
handler.replace_tree_queue(
TreeQueueManager(
queue_update_callback=handler.update_queue_positions,
node_started_callback=handler.mark_node_processing,
)
)