diff --git a/.env.example b/.env.example index 2087089..5758925 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,23 @@ AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token # Get token from: https://t.me/BotFather AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token +# ======================================== +# SSH Access (Optional) +# ======================================== +# Proxmox SSH credentials for remote management +PROXMOX_SSH_HOST=192.168.2.100 +PROXMOX_SSH_USER=root +PROXMOX_SSH_PORT=22 + +# Authentication: Use EITHER password OR key (key is more secure) +# Option 1: Password-based (easier but less secure) +PROXMOX_SSH_PASSWORD=your-proxmox-password + +# Option 2: Key-based (recommended for security) +# PROXMOX_SSH_KEY_FILE=C:/Users/YourName/.ssh/id_rsa +# Generate key: ssh-keygen -t rsa -b 4096 +# Copy to Proxmox: ssh-copy-id root@192.168.2.100 + # ======================================== # Obsidian MCP Integration (Optional) # ======================================== diff --git a/google_tools/gmail_client.py b/google_tools/gmail_client.py index ef88b37..6dc1306 100644 --- a/google_tools/gmail_client.py +++ b/google_tools/gmail_client.py @@ -1,5 +1,8 @@ """Gmail API client for sending and reading emails.""" +import base64 +import os +from pathlib import Path from typing import Dict, List, Optional from googleapiclient.discovery import build @@ -201,13 +204,27 @@ class GmailClient: # Get attachment info if any payload = message.get("payload", {}) attachments = [] - for part in payload.get("parts", []): - if part.get("filename"): - attachments.append({ - "filename": part["filename"], - "mime_type": part.get("mimeType"), - "size": part.get("body", {}).get("size", 0), - }) + + def extract_attachments(parts): + """Recursively extract attachments from message parts.""" + for part in parts: + filename = part.get("filename") + if filename: + body = part.get("body", {}) + attachment_id = body.get("attachmentId") + if attachment_id: + attachments.append({ + "filename": filename, + "attachment_id": attachment_id, + "mime_type": part.get("mimeType"), + "size": body.get("size", 0), + }) + # Recursively check nested parts + if "parts" in part: + extract_attachments(part["parts"]) + + if "parts" in payload: + extract_attachments(payload["parts"]) email_data["attachments"] = attachments @@ -218,3 +235,62 @@ class GmailClient: except HttpError as e: return {"success": False, "error": str(e)} + + def download_attachment( + self, + message_id: str, + attachment_id: str, + filename: str, + output_dir: str = "downloads", + ) -> Dict: + """Download an email attachment. + + Args: + message_id: Gmail message ID + attachment_id: Attachment ID from the message + filename: Original filename of the attachment + output_dir: Directory to save the attachment (default: "downloads") + + Returns: + Dict with success status and file path or error + """ + if not self.service: + if not self._initialize_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + # Get the attachment data + attachment = ( + self.service.users() + .messages() + .attachments() + .get(userId="me", messageId=message_id, id=attachment_id) + .execute() + ) + + # Decode the attachment data + file_data = base64.urlsafe_b64decode(attachment["data"]) + + # Create output directory if it doesn't exist + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Save the file + file_path = output_path / filename + with open(file_path, "wb") as f: + f.write(file_data) + + return { + "success": True, + "file_path": str(file_path), + "filename": filename, + "size": len(file_data), + } + + except HttpError as e: + return {"success": False, "error": str(e)} + except Exception as e: + return {"success": False, "error": f"Failed to save attachment: {str(e)}"} diff --git a/llm_interface.py b/llm_interface.py index a6bd95c..ff466fc 100644 --- a/llm_interface.py +++ b/llm_interface.py @@ -41,6 +41,7 @@ if not logger.handlers: # Try to import Agent SDK (optional dependency) try: from claude_agent_sdk import ( + AssistantMessage, ClaudeAgentOptions, ResultMessage, ) @@ -345,8 +346,12 @@ class LLMInterface: """ try: from mcp_tools import file_system_server + from mcp_servers.mcp_ssh import ssh_mcp_server - mcp_servers = {"file_system": file_system_server} + mcp_servers = { + "file_system": file_system_server, + "ssh": ssh_mcp_server, + } # All tools registered in the MCP server allowed_tools = [ @@ -356,6 +361,9 @@ class LLMInterface: "edit_file", "list_directory", "run_command", + # SSH tools + "ssh_execute", + "ssh_file_upload", # Web tool "web_fetch", # Zettelkasten tools @@ -404,6 +412,46 @@ class LLMInterface: except Exception as e: print(f"[LLM] Obsidian MCP unavailable: {e}") + # Conditionally add Cloudflare Code Mode MCP server + try: + from mcp_servers.cloudflare.cloudflare_mcp import ( + is_cloudflare_enabled, + get_cloudflare_server_config, + CLOUDFLARE_TOOLS, + ) + + if is_cloudflare_enabled(): + cloudflare_config = get_cloudflare_server_config() + mcp_servers["cloudflare"] = cloudflare_config + allowed_tools.extend(CLOUDFLARE_TOOLS) + print("[LLM] Cloudflare MCP server registered (2 tools: search, execute)") + else: + print("[LLM] Cloudflare MCP disabled or no API token set") + except ImportError: + pass + except Exception as e: + print(f"[LLM] Cloudflare MCP unavailable: {e}") + + # Conditionally add Loki MCP server (homelab log querying) + try: + from mcp_servers.loki.loki_mcp import ( + is_loki_enabled, + get_loki_server_config, + LOKI_TOOLS, + ) + + if is_loki_enabled(): + loki_config = get_loki_server_config() + mcp_servers["loki"] = loki_config + allowed_tools.extend(LOKI_TOOLS) + print(f"[LLM] Loki MCP server registered ({len(LOKI_TOOLS)} tools: {', '.join(LOKI_TOOLS)})") + else: + print("[LLM] Loki MCP disabled") + except ImportError: + pass + except Exception as e: + print(f"[LLM] Loki MCP unavailable: {e}") + def _stderr_callback(line: str) -> None: """Log Claude CLI stderr for debugging transport failures.""" logger.debug("[CLI stderr] %s", line) @@ -517,15 +565,20 @@ class LLMInterface: # Collect text from AssistantMessage objects if isinstance(message, AssistantMessage): + logger.debug(f"[LLM] AssistantMessage: has_content={hasattr(message, 'content')}") if hasattr(message, 'content') and message.content: # Extract text from content blocks if isinstance(message.content, str): assistant_messages.append(message.content) + logger.debug(f"[LLM] → Collected string: {len(message.content)} chars") elif isinstance(message.content, list): for block in message.content: if hasattr(block, 'type') and block.type == 'text': if hasattr(block, 'text'): assistant_messages.append(block.text) + logger.debug(f"[LLM] → Collected text block: {len(block.text)} chars") + else: + logger.debug(f"[LLM] → AssistantMessage has no content or empty") if isinstance(message, ResultMessage): # Use ResultMessage.result if available, otherwise use collected assistant messages @@ -537,7 +590,9 @@ class LLMInterface: getattr(message, "num_turns", "?"), ) if not message.result and assistant_messages: - logger.debug(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages") + logger.info(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages") + elif not message.result and not assistant_messages: + logger.warning(f"[LLM] PROBLEM: Both ResultMessage.result and assistant_messages are empty!") break # Log non-result messages to detect loops diff --git a/mcp_servers/mcp_ssh.py b/mcp_servers/mcp_ssh.py new file mode 100644 index 0000000..08369e0 --- /dev/null +++ b/mcp_servers/mcp_ssh.py @@ -0,0 +1,261 @@ +"""SSH MCP Server for remote command execution via SSH. + +Provides SSH access to remote hosts for the bot. +""" + +import asyncio +from typing import Any, Dict + +try: + import paramiko + PARAMIKO_AVAILABLE = True +except ImportError: + PARAMIKO_AVAILABLE = False + +from claude_agent_sdk import tool, create_sdk_mcp_server + + +@tool( + name="ssh_execute", + description="Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.", + input_schema={ + "host": str, + "username": str, + "password": str, + "key_filename": str, + "command": str, + "port": int, + }, +) +async def ssh_execute(args: Dict[str, Any]) -> Dict[str, Any]: + """Execute a command on a remote host via SSH.""" + if not PARAMIKO_AVAILABLE: + return { + "content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}], + "isError": True + } + + host = args.get("host") + username = args.get("username") + password = args.get("password") + key_filename = args.get("key_filename") + command = args.get("command") + port = args.get("port", 22) + + if not all([host, username, command]): + return { + "content": [{"type": "text", "text": "Error: Missing required parameters: host, username, command"}], + "isError": True + } + + if not password and not key_filename: + return { + "content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}], + "isError": True + } + + try: + # Run SSH command in thread pool to avoid blocking + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + _execute_ssh_sync, + host, + port, + username, + password, + key_filename, + command + ) + + # Format result as MCP-compliant text content + if result["success"]: + output_parts = [f"SSH command executed on {result['host']} (auth: {result['auth_method']})"] + output_parts.append(f"Exit code: {result['exit_code']}") + + if result["stdout"]: + stdout = result["stdout"] + if len(stdout) > 5000: + stdout = stdout[:5000] + "\n... (stdout truncated)" + output_parts.append(f"\nSTDOUT:\n{stdout}") + + if result["stderr"]: + stderr = result["stderr"] + if len(stderr) > 5000: + stderr = stderr[:5000] + "\n... (stderr truncated)" + output_parts.append(f"\nSTDERR:\n{stderr}") + + if not result["stdout"] and not result["stderr"]: + output_parts.append("\n(no output)") + + return { + "content": [{"type": "text", "text": "\n".join(output_parts)}], + "isError": result["exit_code"] != 0 + } + else: + return { + "content": [{"type": "text", "text": f"SSH Error: {result['error']}"}], + "isError": True + } + + except Exception as e: + return { + "content": [{"type": "text", "text": f"SSH execution failed: {str(e)}"}], + "isError": True + } + + +def _execute_ssh_sync(host: str, port: int, username: str, password: str, key_filename: str, command: str) -> Dict[str, Any]: + """Synchronous SSH execution (runs in thread pool).""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + # Build connection parameters + connect_kwargs = { + "hostname": host, + "port": port, + "username": username, + "timeout": 30, + } + + # Use key-based auth if key_filename provided, otherwise use password + if key_filename: + connect_kwargs["key_filename"] = key_filename + else: + connect_kwargs["password"] = password + + client.connect(**connect_kwargs) + + stdin, stdout, stderr = client.exec_command(command) + + stdout_text = stdout.read().decode('utf-8') + stderr_text = stderr.read().decode('utf-8') + exit_code = stdout.channel.recv_exit_status() + + return { + "success": True, + "stdout": stdout_text, + "stderr": stderr_text, + "exit_code": exit_code, + "host": host, + "auth_method": "key" if key_filename else "password", + } + finally: + client.close() + + +@tool( + name="ssh_file_upload", + description="Upload a file to a remote host via SFTP. Returns success status and file paths.", + input_schema={ + "host": str, + "username": str, + "password": str, + "key_filename": str, + "local_path": str, + "remote_path": str, + "port": int, + }, +) +async def ssh_file_upload(args: Dict[str, Any]) -> Dict[str, Any]: + """Upload a file to a remote host via SFTP.""" + if not PARAMIKO_AVAILABLE: + return { + "content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}], + "isError": True + } + + host = args.get("host") + username = args.get("username") + password = args.get("password") + key_filename = args.get("key_filename") + local_path = args.get("local_path") + remote_path = args.get("remote_path") + port = args.get("port", 22) + + if not all([host, username, local_path, remote_path]): + return { + "content": [{"type": "text", "text": "Error: Missing required parameters: host, username, local_path, remote_path"}], + "isError": True + } + + if not password and not key_filename: + return { + "content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}], + "isError": True + } + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + _upload_file_sync, + host, + port, + username, + password, + key_filename, + local_path, + remote_path + ) + + # Format result as MCP-compliant text content + if result["success"]: + text = ( + f"File uploaded successfully via SFTP\n" + f"Host: {result['host']}\n" + f"Auth: {result['auth_method']}\n" + f"Local: {result['local_path']}\n" + f"Remote: {result['remote_path']}" + ) + return { + "content": [{"type": "text", "text": text}] + } + else: + return { + "content": [{"type": "text", "text": f"SFTP Error: {result['error']}"}], + "isError": True + } + + except Exception as e: + return { + "content": [{"type": "text", "text": f"SFTP upload failed: {str(e)}"}], + "isError": True + } + + +def _upload_file_sync(host: str, port: int, username: str, password: str, key_filename: str, local_path: str, remote_path: str) -> Dict[str, Any]: + """Synchronous SFTP upload (runs in thread pool).""" + transport = paramiko.Transport((host, port)) + + try: + # Use key-based auth if key_filename provided, otherwise use password + if key_filename: + private_key = paramiko.RSAKey.from_private_key_file(key_filename) + transport.connect(username=username, pkey=private_key) + else: + transport.connect(username=username, password=password) + + sftp = paramiko.SFTPClient.from_transport(transport) + + sftp.put(local_path, remote_path) + + return { + "success": True, + "local_path": local_path, + "remote_path": remote_path, + "host": host, + "auth_method": "key" if key_filename else "password", + } + finally: + sftp.close() if 'sftp' in locals() else None + transport.close() + + +# Create the MCP server +ssh_mcp_server = create_sdk_mcp_server( + name="ssh", + version="1.0.0", + tools=[ssh_execute, ssh_file_upload], +) diff --git a/requirements.txt b/requirements.txt index 1e59a48..1bdb264 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,6 @@ python-dotenv>=1.0.0 # Web fetching dependencies httpx>=0.27.0 beautifulsoup4>=4.12.0 + +# SSH dependencies +paramiko>=3.4.0 diff --git a/tools.py b/tools.py index bce2289..08c174c 100644 --- a/tools.py +++ b/tools.py @@ -190,6 +190,33 @@ TOOL_DEFINITIONS = [ "required": ["message_id"], }, }, + { + "name": "download_attachment", + "description": "Download an email attachment from Gmail. Use get_email first to get attachment IDs.", + "input_schema": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "Gmail message ID containing the attachment", + }, + "attachment_id": { + "type": "string", + "description": "Attachment ID from the email (obtained from get_email)", + }, + "filename": { + "type": "string", + "description": "Original filename of the attachment", + }, + "output_dir": { + "type": "string", + "description": "Directory to save the file (default: 'downloads')", + "default": "downloads", + }, + }, + "required": ["message_id", "attachment_id", "filename"], + }, + }, # Calendar tools { "name": "read_calendar", @@ -411,6 +438,13 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any message_id=tool_input["message_id"], format_type=tool_input.get("format", "text"), ) + elif tool_name == "download_attachment": + result_str = _download_attachment( + message_id=tool_input["message_id"], + attachment_id=tool_input["attachment_id"], + filename=tool_input["filename"], + output_dir=tool_input.get("output_dir", "downloads"), + ) elif tool_name == "read_calendar": result_str = _read_calendar( days_ahead=tool_input.get("days_ahead", 7), @@ -844,7 +878,10 @@ def _get_email(message_id: str, format_type: str = "text") -> str: output.append(f"\n{email_data.get('body', '')}") if email_data.get("attachments"): - output.append(f"\nAttachments: {', '.join(email_data['attachments'])}") + output.append("\nAttachments:") + for att in email_data["attachments"]: + att_info = f" - {att['filename']} ({att.get('size', 0)} bytes, ID: {att.get('attachment_id', 'N/A')})" + output.append(att_info) full_output = "\n".join(output) if len(full_output) > _MAX_TOOL_OUTPUT: @@ -855,6 +892,36 @@ def _get_email(message_id: str, format_type: str = "text") -> str: return f"Error getting email: {result.get('error', 'Unknown error')}" +def _download_attachment( + message_id: str, + attachment_id: str, + filename: str, + output_dir: str = "downloads", +) -> str: + """Download an email attachment.""" + gmail_client, _, _ = _initialize_google_clients() + + if not gmail_client: + return "Error: Google not authorized. Run: python bot_runner.py --setup-google" + + result = gmail_client.download_attachment( + message_id=message_id, + attachment_id=attachment_id, + filename=filename, + output_dir=output_dir, + ) + + if result["success"]: + return ( + f"Downloaded attachment successfully:\n" + f" File: {result['filename']}\n" + f" Path: {result['file_path']}\n" + f" Size: {result['size']:,} bytes" + ) + else: + return f"Error downloading attachment: {result.get('error', 'Unknown error')}" + + def _read_calendar( days_ahead: int = 7, calendar_id: str = "primary",