diff --git a/gsd-openclaw/src/gsd_openclaw/orchestrator.py b/gsd-openclaw/src/gsd_openclaw/orchestrator.py new file mode 100644 index 0000000..71721eb --- /dev/null +++ b/gsd-openclaw/src/gsd_openclaw/orchestrator.py @@ -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:] + ) diff --git a/gsd-openclaw/tests/test_orchestrator.py b/gsd-openclaw/tests/test_orchestrator.py index e07da5a..3f79178 100644 --- a/gsd-openclaw/tests/test_orchestrator.py +++ b/gsd-openclaw/tests/test_orchestrator.py @@ -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