Add SSH MCP server and Gmail attachment download
Features: - SSH MCP server with two tools: * ssh_execute: Run commands on remote hosts via SSH * ssh_file_upload: Upload files via SFTP - Support for both password and SSH key authentication - Auto-accept SSH host keys (AutoAddPolicy) for homelab use - Gmail attachment download functionality - Added download_attachment tool for Gmail API Technical changes: - Created mcp_servers/mcp_ssh.py with MCP-compliant text output - Updated llm_interface.py to load SSH MCP server - Added paramiko>=3.4.0 to requirements.txt - Updated .env.example with SSH configuration template - Enhanced gmail_client.py with download_attachment() method - Added download_attachment tool handler in tools.py SSH credentials configured via environment variables: - PROXMOX_SSH_HOST, PROXMOX_SSH_USER, PROXMOX_SSH_PORT - PROXMOX_SSH_PASSWORD (or) PROXMOX_SSH_KEY_FILE Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
17
.env.example
17
.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)
|
||||
# ========================================
|
||||
|
||||
@@ -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)}"}
|
||||
|
||||
@@ -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
|
||||
|
||||
261
mcp_servers/mcp_ssh.py
Normal file
261
mcp_servers/mcp_ssh.py
Normal file
@@ -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],
|
||||
)
|
||||
@@ -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
|
||||
|
||||
69
tools.py
69
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",
|
||||
|
||||
Reference in New Issue
Block a user