feat(03-03): implement Orchestrator class
- Add Orchestrator class with handle_message() and handle_callback() entry points - Command parsing via regex: /gsd:* pattern extraction - Auto-session-creation per CONTEXT.md decision - Error handling with try/except returning format_error() cards - Approval detection integration with ApprovalHandler - Output truncation to 2000 chars for Feishu card limits - 18 tests passing with full coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Main command router and orchestrator for OpenClaw skill invocation.
|
||||
|
||||
This module provides the central command routing system that:
|
||||
- Receives chat messages via handle_message()
|
||||
- Routes commands through session management and command execution
|
||||
- Handles approval workflows and progress updates
|
||||
- Returns formatted Feishu card responses
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager
|
||||
from gsd_openclaw.command_executor import CommandExecutor, CommandResult
|
||||
from gsd_openclaw.approval_handler import ApprovalHandler
|
||||
from gsd_openclaw.progress_monitor import ProgressMonitor
|
||||
from gsd_openclaw.response_formatter import FeishuResponseFormatter
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Central command router for OpenClaw skill invocation.
|
||||
|
||||
This class provides the main entry point for handling GSD commands
|
||||
from chat messages, integrating session management, command execution,
|
||||
approval handling, and response formatting.
|
||||
|
||||
Attributes:
|
||||
tmux_manager: TmuxSessionManager for session lifecycle.
|
||||
formatter: FeishuResponseFormatter for card creation.
|
||||
executor: CommandExecutor for running commands.
|
||||
approval_handler: ApprovalHandler for interactive approvals.
|
||||
progress_monitor: ProgressMonitor for progress updates.
|
||||
"""
|
||||
|
||||
# Regex pattern for /gsd:* command parsing
|
||||
COMMAND_PATTERN = re.compile(r'^/gsd:(\S+)\s*(.*)$')
|
||||
|
||||
# Maximum characters for card content (Feishu limit)
|
||||
MAX_OUTPUT_LENGTH = 2000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmux_manager: Optional[TmuxSessionManager] = None,
|
||||
formatter: Optional[FeishuResponseFormatter] = None
|
||||
):
|
||||
"""Initialize Orchestrator.
|
||||
|
||||
Args:
|
||||
tmux_manager: Optional TmuxSessionManager instance.
|
||||
If not provided, a new one will be created.
|
||||
formatter: Optional FeishuResponseFormatter instance.
|
||||
If not provided, a new one will be created.
|
||||
"""
|
||||
self.tmux_manager = tmux_manager or TmuxSessionManager()
|
||||
self.formatter = formatter or FeishuResponseFormatter()
|
||||
self.executor = CommandExecutor()
|
||||
self.approval_handler = ApprovalHandler(self.formatter)
|
||||
self.progress_monitor = ProgressMonitor(self.formatter)
|
||||
|
||||
def parse_command(self, message: str) -> Optional[Tuple[str, str]]:
|
||||
"""Parse a message for /gsd:* command pattern.
|
||||
|
||||
Args:
|
||||
message: The message text to parse.
|
||||
|
||||
Returns:
|
||||
Tuple of (command, args) if matched, None otherwise.
|
||||
"""
|
||||
match = self.COMMAND_PATTERN.match(message.strip())
|
||||
if match:
|
||||
command = match.group(1)
|
||||
args = match.group(2).strip()
|
||||
return (command, args)
|
||||
return None
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
channel: str,
|
||||
context: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle an incoming chat message.
|
||||
|
||||
This is the main entry point for processing GSD commands.
|
||||
It handles:
|
||||
1. Command parsing
|
||||
2. Session management (auto-create if needed)
|
||||
3. Command execution
|
||||
4. Approval detection
|
||||
5. Response formatting
|
||||
|
||||
Args:
|
||||
user_id: ID of the user sending the message.
|
||||
message: The message text.
|
||||
channel: The channel where the message was sent.
|
||||
context: Additional context including project name.
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict as response.
|
||||
"""
|
||||
try:
|
||||
# Parse command
|
||||
parsed = self.parse_command(message)
|
||||
if parsed is None:
|
||||
return self.formatter.format_info(
|
||||
title="无效命令",
|
||||
content="请使用 /gsd:* 格式的命令。例如: /gsd:status"
|
||||
)
|
||||
|
||||
command, args = parsed
|
||||
|
||||
# Get project name from context
|
||||
project_name = context.get("project", "default")
|
||||
|
||||
# Get or create session (auto-create per CONTEXT.md)
|
||||
session = self.tmux_manager.get_session(project_name)
|
||||
if session is None:
|
||||
session = self.tmux_manager.create_session(project_name)
|
||||
|
||||
# Build the full command
|
||||
full_command = f"/gsd:{command}"
|
||||
if args:
|
||||
full_command = f"{full_command} {args}"
|
||||
|
||||
# Execute command
|
||||
result = self.executor.execute(session, full_command)
|
||||
|
||||
# Truncate output if needed
|
||||
output = result.output
|
||||
if len(output) > self.MAX_OUTPUT_LENGTH:
|
||||
output = output[-self.MAX_OUTPUT_LENGTH:]
|
||||
|
||||
# Check for approval request
|
||||
approval_context = self.approval_handler.detect_approval_request(result.output)
|
||||
if approval_context:
|
||||
return self.approval_handler.create_approval_card(
|
||||
context=approval_context,
|
||||
session_id=session.session_id,
|
||||
project_name=project_name
|
||||
)
|
||||
|
||||
# Return appropriate response based on result
|
||||
if result.has_error:
|
||||
return self.formatter.format_error(
|
||||
title="命令执行失败",
|
||||
content=output
|
||||
)
|
||||
else:
|
||||
return self.formatter.format_success(
|
||||
title="执行成功",
|
||||
content=output
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="命令执行失败",
|
||||
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
|
||||
)
|
||||
|
||||
def handle_callback(self, callback_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle a button callback from an approval card.
|
||||
|
||||
This processes approval/reject actions and sends the
|
||||
response to the tmux session.
|
||||
|
||||
Args:
|
||||
callback_data: Dictionary containing:
|
||||
- action: "approve" or "reject"
|
||||
- approval_id: The ID of the approval
|
||||
- project: The project name
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict as confirmation response.
|
||||
"""
|
||||
try:
|
||||
action = callback_data.get("action", "")
|
||||
approval_id = callback_data.get("approval_id", "")
|
||||
project_name = callback_data.get("project", "default")
|
||||
|
||||
# Handle callback via approval handler
|
||||
result = self.approval_handler.handle_callback(callback_data)
|
||||
|
||||
if result.startswith("error:"):
|
||||
return self.formatter.format_error(
|
||||
title="操作失败",
|
||||
content=result
|
||||
)
|
||||
|
||||
# Get session
|
||||
session = self.tmux_manager.get_session(project_name)
|
||||
if session is None:
|
||||
return self.formatter.format_error(
|
||||
title="会话不存在",
|
||||
content="无法找到对应的会话"
|
||||
)
|
||||
|
||||
# Send response to tmux
|
||||
if result == "approved":
|
||||
response_text = "y" # Standard confirmation
|
||||
self.executor.send_command(session.tmux_session.active_pane, response_text)
|
||||
return self.formatter.format_success(
|
||||
title="已确认",
|
||||
content="操作已继续执行"
|
||||
)
|
||||
elif result == "rejected":
|
||||
response_text = "n" # Standard rejection
|
||||
self.executor.send_command(session.tmux_session.active_pane, response_text)
|
||||
return self.formatter.format_info(
|
||||
title="已取消",
|
||||
content="操作已取消"
|
||||
)
|
||||
else:
|
||||
return self.formatter.format_error(
|
||||
title="未知结果",
|
||||
content=f"处理结果: {result}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="操作失败",
|
||||
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
|
||||
)
|
||||
@@ -116,6 +116,8 @@ class TestHandleMessage:
|
||||
"""handle_message should execute command and return success card."""
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
mock_orchestrator.executor.execute.return_value = CommandResult(
|
||||
@@ -125,6 +127,8 @@ class TestHandleMessage:
|
||||
error_type=None
|
||||
)
|
||||
|
||||
# Ensure no approval is detected
|
||||
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
|
||||
mock_orchestrator.formatter.format_success.return_value = {"card": "success"}
|
||||
|
||||
result = mock_orchestrator.handle_message(
|
||||
@@ -142,6 +146,9 @@ class TestHandleMessage:
|
||||
def test_handle_message_returns_error_card_when_command_fails(self, mock_orchestrator):
|
||||
"""handle_message should return error card when command fails."""
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
mock_orchestrator.executor.execute.return_value = CommandResult(
|
||||
@@ -151,6 +158,8 @@ class TestHandleMessage:
|
||||
error_type="error"
|
||||
)
|
||||
|
||||
# Ensure no approval is detected
|
||||
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
|
||||
mock_orchestrator.formatter.format_error.return_value = {"card": "error"}
|
||||
|
||||
result = mock_orchestrator.handle_message(
|
||||
@@ -166,6 +175,7 @@ class TestHandleMessage:
|
||||
def test_handle_message_returns_approval_card_when_approval_detected(self, mock_orchestrator):
|
||||
"""handle_message should return approval card when approval is detected."""
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
mock_orchestrator.executor.execute.return_value = CommandResult(
|
||||
@@ -196,6 +206,9 @@ class TestHandleMessage:
|
||||
def test_handle_message_truncates_output_to_2000_chars(self, mock_orchestrator):
|
||||
"""handle_message should truncate output to 2000 chars for card limits."""
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
# Create long output
|
||||
@@ -207,6 +220,8 @@ class TestHandleMessage:
|
||||
error_type=None
|
||||
)
|
||||
|
||||
# Ensure no approval is detected
|
||||
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
|
||||
mock_orchestrator.formatter.format_success.return_value = {"card": "success"}
|
||||
|
||||
result = mock_orchestrator.handle_message(
|
||||
@@ -384,6 +399,9 @@ class TestIntegrationFlows:
|
||||
orch = orchestrator_with_real_components
|
||||
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
orch.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
orch.executor.execute.return_value = CommandResult(
|
||||
@@ -409,6 +427,7 @@ class TestIntegrationFlows:
|
||||
orch = orchestrator_with_real_components
|
||||
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
orch.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
orch.executor.execute.return_value = CommandResult(
|
||||
@@ -437,6 +456,9 @@ class TestIntegrationFlows:
|
||||
orch = orchestrator_with_real_components
|
||||
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
orch.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
# First create an approval via handle_message
|
||||
@@ -482,6 +504,9 @@ class TestIntegrationFlows:
|
||||
orch = orchestrator_with_real_components
|
||||
|
||||
mock_tmux_session = MagicMock(spec=TmuxSession)
|
||||
mock_tmux_session.session_id = "test-123"
|
||||
mock_tmux_session.tmux_session = MagicMock()
|
||||
mock_tmux_session.tmux_session.active_pane = MagicMock()
|
||||
orch.tmux_manager.get_session.return_value = mock_tmux_session
|
||||
|
||||
# First create an approval via handle_message
|
||||
|
||||
Reference in New Issue
Block a user