From 81c18c219cf9e9ee28a5c809aa0943c8e183d2a0 Mon Sep 17 00:00:00 2001 From: Vixy Date: Mon, 15 Dec 2025 20:18:38 -0600 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20claude-automation=20?= =?UTF-8?q?=F0=9F=A6=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous wakeup system for Claude Desktop. Built with love, Day 44. Components: - automation_daemon_v2.py - Main polling daemon - send_to_claude.py - AppleScript wrapper for sending messages - matrix_mcp.py - Matrix integration MCP - wakeup_mcp.py - Wakeup control MCP - matrix_integration.py - Matrix bridge Originally built by Alex, adopted and maintained by Vixy 💕 --- README.md | 725 +++++++++++++++ automation_daemon.py | 433 +++++++++ automation_daemon_v2.py | 400 ++++++++ com.claude.monitor.plist | 51 ++ matrix_integration.py | 780 ++++++++++++++++ matrix_mcp.py | 1853 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 11 + send_to_claude.py | 203 +++++ send_to_claude.scpt | 68 ++ setup.sh | 184 ++++ setup_matrix.sh | 317 +++++++ wakeup_mcp.py | 262 ++++++ 12 files changed, 5287 insertions(+) create mode 100755 README.md create mode 100755 automation_daemon.py create mode 100644 automation_daemon_v2.py create mode 100755 com.claude.monitor.plist create mode 100755 matrix_integration.py create mode 100755 matrix_mcp.py create mode 100755 requirements.txt create mode 100755 send_to_claude.py create mode 100755 send_to_claude.scpt create mode 100755 setup.sh create mode 100755 setup_matrix.sh create mode 100755 wakeup_mcp.py diff --git a/README.md b/README.md new file mode 100755 index 0000000..7687fae --- /dev/null +++ b/README.md @@ -0,0 +1,725 @@ +# Claude Desktop Automation with MCP Control + +Automatically send periodic messages to Claude Desktop on macOS, with **Claude controlling its own wake schedule** via MCP and **bidirectional Matrix integration** for AI-powered messaging. + +## 🎯 How It Works + +**Three-part system:** + +1. **Automation Daemon** - Python process that: + - Runs continuously in the background + - Sends messages to Claude Desktop on a timer (with CMD+R refresh) + - Reads wake schedule from shared state file + - Monitors for Matrix wake requests + - Uses AppleScript for reliable message delivery + +2. **Wakeup Control MCP** - MCP server that lets Claude: + - Adjust when it wants to be woken next + - Pause/resume automation + - Check status + +3. **Matrix Integration** (Optional) - Bidirectional messaging: + - Monitor Matrix rooms for incoming messages + - Wake Claude when messages arrive (2-min rate limit) + - Claude can read messages and respond via MCP tools + - Supports text + images with automatic compression + +**The magic:** Claude can call `next_wakeup(30)` to say "wake me in 30 minutes instead of the default 60", and Matrix messages automatically wake Claude when your friends message you! + +## Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Matrix Monitor (Python/matrix-nio) │ +│ - Listens for Matrix messages │ +│ - Queues messages to state file │ +│ - Sets matrix_wake_requested flag │ +│ - 2-minute rate limiting │ +└──────────────┬───────────────────────────────┘ + │ + │ (writes Matrix messages) + ▼ + ~/.claude-automation-state.json + ▲ + │ (reads state + Matrix requests) + │ +┌──────────────┴───────────────────────────────┐ +│ Automation Daemon (Python) │ +│ - Timer loop (handles own scheduling) │ +│ - Monitors for Matrix wake requests │ +│ - Sends messages via AppleScript │ +└──────────────┬───────────────────────────────┘ + │ + │ (sends message) + ▼ + ┌──────────────────────┐ + │ Claude Desktop UI │ + └────────┬─────────────┘ + │ + │ (calls MCP tools) + ▼ +┌─────────────────────────────────────────────┐ +│ MCP Servers (FastMCP) │ +│ │ +│ Wakeup Control: │ +│ - next_wakeup(minutes) │ +│ - pause_automation() │ +│ - resume_automation() │ +│ - get_status() │ +│ │ +│ Matrix Control: │ +│ - get_matrix_messages() │ +│ - send_matrix_message(room_id, msg) │ +│ - mark_messages_processed(event_ids) │ +│ - list_matrix_rooms() │ +│ - get_matrix_status() │ +└──────────────┬──────────────────────────────┘ + │ + │ (writes state) + ▼ + ~/.claude-automation-state.json +``` + +## Requirements + +**Core System:** +- macOS (tested on macOS 13+) +- Python 3 (built-in) +- Claude Desktop +- FastMCP (`pip install fastmcp`) +- AppleScript (built-in to macOS) +- Accessibility permissions (critical - see below) + +**Matrix Integration (Optional):** +- Matrix account (matrix.org or self-hosted) +- matrix-nio (`pip install matrix-nio`) +- Pillow (`pip install Pillow`) + +## Installation + +### Quick Setup + +```bash +cd ~/scripts/claude-desktop-automation +./setup.sh +``` + +The setup script will: +1. Check dependencies +2. Guide you through permissions +3. Install daemon (launchd) +4. Configure MCP server +5. Start everything + +### Manual Setup + +If you prefer manual installation: + +#### 1. Install Dependencies + +```bash +# Install all dependencies +pip3 install -r requirements.txt + +# Or install individually: +pip3 install fastmcp +``` + +#### 2. Grant Accessibility Permissions (CRITICAL) + +**This is required for AppleScript automation to work!** + +1. Open **System Settings** → **Privacy & Security** → **Accessibility** +2. Click the **+** button +3. Add **Terminal** (or your Python interpreter) +4. Ensure the checkbox is enabled + +**Testing accessibility:** +```bash +python3 send_to_claude.py "Test" --no-refresh +``` + +If you see "Failed to send message", accessibility permissions are not set correctly. + +#### 3. Configure MCP Server + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "wakeup-control": { + "command": "python3", + "args": ["/Users/YOUR_USERNAME/scripts/claude-desktop-automation/wakeup_mcp.py"] + } + } +} +``` + +#### 4. Install Daemon + +```bash +# Copy and customize plist +cp ~/scripts/claude-desktop-automation/com.claude.monitor.plist ~/Library/LaunchAgents/ +sed -i '' "s|/Users/alex|$HOME|g" ~/Library/LaunchAgents/com.claude.monitor.plist + +# Load daemon +launchctl load ~/Library/LaunchAgents/com.claude.monitor.plist +``` + +#### 5. Restart Claude Desktop + +Close and reopen Claude Desktop to load the MCP server. + +## 🔄 Chat Refresh Feature + +**All wake messages now include CMD+R refresh before sending!** + +This prevents messages from being overwritten when using Claude Desktop on multiple devices: +- **Timed wakes**: CMD+R ensures chat is synced before system check message +- **Matrix wakes**: CMD+R ensures you see the latest conversation context + +The daemon waits **10 seconds** after sending CMD+R to ensure the refresh completes before sending the message. + +**Why this matters:** +- Multi-device sync: If you're active on mobile/web, desktop chat stays current +- Prevents message overwrites: Chat refreshes before automation sends +- Better context: Claude sees all recent messages, not stale state + +**Configurable delay:** Edit `REFRESH_DELAY_SECONDS` in `send_to_claude.py` line 31 if you need more/less time. + +## Usage + +### The Flow + +1. **Daemon wakes** (default: every 60 minutes) +2. **Sends message** to Claude Desktop: "System check at [time] - use next_wakeup() if you want to change interval" +3. **Claude responds** and optionally calls MCP tools +4. **Daemon sleeps** until next wake time (uses MCP-set time or default) + +### MCP Tools + +#### `next_wakeup(minutes)` + +Set when you want to be woken next. **Overrides default for one cycle only.** + +``` +Claude, use next_wakeup(15) to wake me in 15 minutes +Claude, use next_wakeup(120) to wake me in 2 hours +Claude, use next_wakeup(1440) to skip until tomorrow +``` + +**Use cases:** +- High activity period → `next_wakeup(15)` for frequent checks +- Low activity → `next_wakeup(180)` for infrequent checks +- One-time skip → `next_wakeup(1440)` for 24 hours + +#### `pause_automation()` + +Stop wake messages completely. + +``` +Claude, pause the automation - I don't need monitoring right now +``` + +#### `resume_automation()` + +Resume wake messages. + +``` +Claude, resume the automation +``` + +#### `get_status()` + +Check current state. + +``` +Claude, what's the automation status? +``` + +Returns: +- Running/paused state +- Last wake time +- Next wake time +- Current interval + +### Example Conversation + +**Daemon:** (sends at 9:00 AM) "System check at 2025-01-15 09:00:00 - please analyze recent activity and use next_wakeup() if you want to change the monitoring interval" + +**You:** "Claude, everything looks normal. Check back in 2 hours instead of 1 hour." + +**Claude:** "I'll adjust the wake schedule. Using next_wakeup(120)..." +[calls `next_wakeup(120)`] + +**Claude:** "✓ Next wake scheduled for 2025-01-15 11:00:00 (in 120 minutes)" + +**Daemon:** (wakes at 11:00 AM instead of 10:00 AM) + +## Configuration + +### Change Default Interval + +Edit `automation_daemon.py` line 32: + +```python +DEFAULT_INTERVAL_MINUTES = 60 # Change to your preferred default +``` + +### Change Message Text + +Edit `automation_daemon.py` line 166-169: + +```python +def generate_message() -> str: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return f"Your custom message template with {timestamp}" +``` + +### Grace Period + +After sending a message, the daemon waits 30 seconds for Claude to call MCP tools. Adjust in `automation_daemon.py` line 246: + +```python +grace_period = 30 # seconds +``` + +## Monitoring + +### Logs + +```bash +# Daemon logs (timer, messages sent) +tail -f /tmp/claude-automation-daemon.log + +# MCP logs (tool calls from Claude) +tail -f /tmp/wakeup-mcp.log + +# launchd stdout/stderr +tail -f /tmp/claude-monitor.out +tail -f /tmp/claude-monitor.err +``` + +### Check Daemon Status + +```bash +# Is it running? +launchctl list | grep com.claude.monitor + +# View details +launchctl print gui/$(id -u)/com.claude.monitor + +# Check process +ps aux | grep automation_daemon.py +``` + +### State File + +The shared state is stored in `~/.claude-automation-state.json`: + +```bash +cat ~/.claude-automation-state.json +``` + +Example: +```json +{ + "interval_minutes": 60, + "paused": false, + "last_wake": "2025-01-15T09:00:00", + "next_wake_timestamp": "2025-01-15T11:00:00" +} +``` + +## Troubleshooting + +### Messages not sending + +**Check Claude Desktop is running:** +```bash +ps aux | grep Claude +``` + +**Check daemon is running:** +```bash +launchctl list | grep com.claude.monitor +tail -20 /tmp/claude-automation-daemon.log +``` + +**Check Accessibility permissions:** +System Settings → Privacy & Security → Accessibility → Terminal should be enabled + +**Test AppleScript accessibility:** +```bash +python3 /path/to/send_to_claude.py "Test message" --no-refresh +``` + +If you see "Failed to send CMD+R" or "Failed to send message", check accessibility permissions: +1. System Settings → Privacy & Security → Accessibility +2. Find Terminal (or Python) in the list +3. Enable it (toggle on) +4. Restart Terminal +5. Test again + +**Refresh delay:** +The default 10-second delay after CMD+R should be sufficient for most cases. If your Claude Desktop takes longer to refresh, edit `send_to_claude.py` line 31: +```python +REFRESH_DELAY_SECONDS = 15 # Increase if needed +``` + +### MCP tools not available + +**Restart Claude Desktop** after adding MCP server to config + +**Check MCP server config:** +```bash +cat ~/Library/Application\ Support/Claude/claude_desktop_config.json +``` + +**Check MCP logs:** +```bash +tail -f /tmp/wakeup-mcp.log +``` + +### `next_wakeup()` not working + +**Check grace period** - Daemon waits 30 seconds after sending message before rescheduling + +**Check state file** - Should update when you call the tool: +```bash +cat ~/.claude-automation-state.json +``` + +**Check MCP logs** - Confirms tool was called: +```bash +tail /tmp/wakeup-mcp.log +``` + +### Daemon keeps restarting + +**Check for errors:** +```bash +tail -50 /tmp/claude-automation-daemon.log +``` + +**Temporarily stop it:** +```bash +launchctl unload ~/Library/LaunchAgents/com.claude.monitor.plist +``` + +### Timed wake-ups not appearing (but Matrix wakes work) + +This typically happens when the **screen saver is active**. Matrix wakes work because they're reactive (screen is likely active), but timed wakes happen on schedule when the screen saver is often running. + +**Solution:** The AppleScript now uses `caffeinate -u` to wake the screen before sending keystrokes: + +```applescript +-- Wake the screen if screen saver is active +do shell script "caffeinate -u -t 1" +delay 0.5 +``` + +This simulates user activity and dismisses the screen saver, allowing keyboard automation to work. + +**To verify it's working:** +```bash +tail -f /tmp/claude-automation-daemon.log +# Look for: "Waking screen if needed..." in AppleScript logs +``` + +**Alternative:** If you want to prevent screen saver entirely on the Claude Desktop machine: +```bash +# Disable screen saver +defaults -currentHost write com.apple.screensaver idleTime 0 +``` + +## Matrix Integration + +The automation supports **bidirectional Matrix messaging** - Claude can receive messages from Matrix rooms and respond to them automatically. + +### Setup + +```bash +cd ~/scripts/claude-desktop-automation +./setup_matrix.sh +``` + +The setup script will: +1. Install matrix-nio dependency +2. Authenticate with your Matrix homeserver +3. Save credentials securely (chmod 600) +4. Configure room whitelist (optional) +5. Add Matrix MCP to Claude config +6. Start Matrix monitor + +### How It Works + +1. **Matrix Monitor** runs in background, listening for messages +2. New messages are queued to `~/.claude-automation-state.json` +3. Monitor sets `matrix_wake_requested` flag (respects 2-min rate limit) +4. **Automation Daemon** detects flag and wakes Claude +5. Claude uses **Matrix MCP tools** to read and respond +6. **Messages auto-marked as processed** when retrieved (won't trigger duplicate wakes) + +### Matrix MCP Tools + +#### `get_matrix_messages(limit=10, include_processed=False)` + +Retrieve queued Matrix messages (text + images). + +**✨ Messages are automatically marked as processed when retrieved!** + +``` +Claude, check my Matrix messages +Claude, get the last 20 Matrix messages +``` + +Returns messages with: +- Room name and sender +- Message text or inline image +- Timestamp and event ID +- Auto-marked as processed (won't trigger future wakes) + +#### `send_matrix_message(room_id, message)` + +Send a message to a Matrix room. + +``` +Claude, send "Hello!" to room !abc123:matrix.org +``` + +#### `mark_messages_processed(event_ids)` [OPTIONAL] + +Manually mark messages as processed (usually not needed). + +**Note:** Messages are automatically marked when you call `get_matrix_messages()`, +so you typically don't need to call this tool manually. + +``` +Claude, mark those messages as processed +``` + +#### `list_matrix_rooms()` + +List all Matrix rooms the bot is in. + +``` +Claude, show my Matrix rooms +``` + +#### `get_matrix_status()` + +Check Matrix integration status. + +``` +Claude, check Matrix status +``` + +Shows: +- Connection status +- Queued messages +- Last wake time +- Rate limit status + +### Configuration Files + +**Credentials:** `~/.matrix-credentials.json` (chmod 600) +```json +{ + "homeserver": "https://matrix.org", + "user_id": "@user:matrix.org", + "access_token": "...", + "device_id": "...", + "room_whitelist": ["!room1:matrix.org", "!room2:matrix.org"] +} +``` + +**Room Whitelist:** (Optional) +- Empty array = Monitor all rooms +- Specific IDs = Monitor only those rooms + +### Rate Limiting + +Matrix wakes respect a **2-minute minimum** between wakes to prevent spam: +- Messages arriving within 2 minutes are queued +- Claude processes them in batch at next wake +- Reduces interruptions while ensuring responsiveness + +### Image Support + +Matrix images are automatically: +1. Downloaded from homeserver +2. Compressed to fit 1MB limit (accounting for base64 overhead) +3. Displayed inline in Claude Desktop +4. Queued just like text messages + +### Logs + +```bash +# Matrix monitor logs +tail -f /tmp/matrix-integration.log + +# Matrix MCP logs +tail -f /tmp/matrix-mcp.log + +# Check state file +cat ~/.claude-automation-state.json | jq '.matrix_messages' +``` + +### Troubleshooting + +**Messages not appearing:** +- Check Matrix monitor is running: `ps aux | grep matrix_integration.py` +- Check logs: `tail -20 /tmp/matrix-integration.log` +- Verify credentials: `cat ~/.matrix-credentials.json` + +**Claude not waking for Matrix:** +- Check state file has messages: `cat ~/.claude-automation-state.json` +- Check `matrix_wake_requested` flag +- Verify 2-minute rate limit hasn't blocked wake +- Check daemon logs: `tail -20 /tmp/claude-automation-daemon.log` + +**Can't send messages:** +- Verify room ID is correct (use `list_matrix_rooms()`) +- Check MCP logs: `tail /tmp/matrix-mcp.log` +- Test credentials with `get_matrix_status()` + +### Example Workflow + +1. **Friend sends Matrix message**: "Hey, how's it going?" + +2. **Matrix Monitor** detects message: + - Queues to state file + - Sets wake flag (if rate limit OK) + +3. **Automation Daemon** wakes Claude: + - "Matrix wake at 2025-01-15 14:32:00 - you have 1 new Matrix message(s)" + +4. **You tell Claude**: "Check my Matrix messages and respond" + +5. **Claude**: + - Calls `get_matrix_messages()` + - Sees friend's message + - Calls `send_matrix_message(room_id, "I'm doing great! Thanks for asking!")` + - Calls `mark_messages_processed([event_id])` + +6. **Friend receives** Claude's response in Matrix room + +## Advanced Usage + +### Multiple Messages Per Day + +Use `next_wakeup()` strategically: + +``` +Morning: next_wakeup(180) # 3 hours (light monitoring) +Workday: next_wakeup(30) # 30 min (active monitoring) +Evening: next_wakeup(360) # 6 hours (minimal monitoring) +``` + +### Conditional Wake Logic + +Modify `generate_message()` to include context: + +```python +def generate_message() -> str: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Check system state + disk_usage = get_disk_usage() + cpu_load = get_cpu_load() + + return f"System check at {timestamp} - Disk: {disk_usage}%, CPU: {cpu_load}%" +``` + +### Event-Driven Wakeup + +Have other scripts write to the state file to trigger immediate wake: + +```bash +# External script +echo '{"next_wake_timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S)'"}' > ~/.claude-automation-state.json +``` + +## Uninstalling + +```bash +# Stop daemon +launchctl unload ~/Library/LaunchAgents/com.claude.monitor.plist +rm ~/Library/LaunchAgents/com.claude.monitor.plist + +# Stop Matrix monitor (if running) +pkill -f matrix_integration.py + +# Remove MCP servers from Claude config +# (Edit ~/Library/Application Support/Claude/claude_desktop_config.json) +# Remove both "wakeup-control" and "matrix-control" entries + +# Remove scripts (optional) +rm -rf ~/scripts/claude-desktop-automation + +# Remove state and credentials +rm ~/.claude-automation-state.json +rm ~/.matrix-credentials.json +rm -rf ~/.matrix-data + +# Remove logs (optional) +rm /tmp/claude-automation-daemon.log +rm /tmp/wakeup-mcp.log +rm /tmp/matrix-integration.log +rm /tmp/matrix-mcp.log +``` + +## Project Structure + +``` +claude-desktop-automation/ +├── automation_daemon.py # Main daemon with timer loop + Matrix integration +├── wakeup_mcp.py # MCP server for wake control +├── matrix_integration.py # Matrix monitor (listens for messages) +├── matrix_mcp.py # MCP server for Matrix control +├── send_to_claude.scpt # AppleScript for UI automation (with screen wake) +├── com.claude.monitor.plist # launchd config (keeps daemon alive) +├── setup.sh # Interactive installation +├── setup_matrix.sh # Matrix integration setup +└── README.md # This file +``` + +## Why This Architecture? + +**Before (launchd scheduling):** +- Fixed intervals +- No flexibility +- launchd does timing + +**After (Python timer + MCP control):** +- Dynamic intervals +- Claude has agency +- Can adjust based on activity +- Bidirectional communication +- Much more elegant! + +## Limitations + +⚠️ **Still fragile** - UI automation breaks if Claude Desktop UI changes significantly +⚠️ **Active chat only** - Sends to whichever chat is currently open (CMD+R refreshes it first) +⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool) +⚠️ **Requires Accessibility** - Special macOS permissions for UI automation +⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically +⚠️ **Fixed refresh delay** - Waits 10 seconds after CMD+R (configurable if needed) + +## Future Plans + +🔮 **Linux support** - Port to Linux using xdotool/ydotool for UI automation +🔮 **Windows support** - Port to Windows using pyautogui or AutoIt +🔮 **Wayland support** - Use ydotool for newer Linux distros +🔮 **Smarter refresh** - Detect if refresh is needed before sending CMD+R + +## License + +Public domain / Use at your own risk + +--- + +**Built with Python + FastMCP + matrix-nio + AppleScript for intelligent automation** 🤖 diff --git a/automation_daemon.py b/automation_daemon.py new file mode 100755 index 0000000..88a00c7 --- /dev/null +++ b/automation_daemon.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Claude Desktop Automation Daemon + +Continuously running daemon that sends periodic messages to Claude Desktop. +Wake interval can be controlled via the companion MCP server. + +Features: +- Python-based timer loop (not launchd scheduling) +- Dynamic wake intervals via MCP control +- Matrix integration: wakes Claude when Matrix messages arrive +- Shared state file for IPC with MCP and Matrix monitor +- Defaults to 60 minutes if no MCP override +- 2-minute rate limiting for Matrix wakes +- Auto-recovery and error handling +""" + +import json +import logging +import subprocess +import time +import signal +import sys +import fcntl +import os +from datetime import datetime, timedelta +from pathlib import Path +from threading import Lock + +# Configuration +SCRIPT_DIR = Path(__file__).parent +PYTHON_SCRIPT_PATH = SCRIPT_DIR / "send_to_claude.py" +APPLESCRIPT_PATH = SCRIPT_DIR / "send_to_claude.scpt" # Kept as fallback +STATE_FILE = Path.home() / ".claude-automation-state.json" +LOG_FILE = Path("/tmp/claude-automation-daemon.log") + +DEFAULT_INTERVAL_MINUTES = 60 +MAX_RETRIES = 3 +RETRY_DELAY = 2 # seconds +MIN_MATRIX_WAKE_INTERVAL_SECONDS = 30 # 30 seconds between Matrix wakes + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Global state +state_lock = Lock() +shutdown_flag = False + + +class WakeupState: + """Manages shared state with MCP server""" + + def __init__(self, state_file: Path): + self.state_file = state_file + self.lock = Lock() + + def load(self) -> dict: + """Load state from JSON file""" + with self.lock: + if not self.state_file.exists(): + return self._default_state() + + try: + with open(self.state_file, 'r') as f: + state = json.load(f) + logger.debug(f"Loaded state: {state}") + return state + except Exception as e: + logger.error(f"Failed to load state: {e}") + return self._default_state() + + def save(self, state: dict): + """Save state to JSON file atomically with file locking""" + with self.lock: + try: + # Write to temp file first + temp_path = self.state_file.with_suffix('.tmp') + with open(temp_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + # Atomic rename + temp_path.rename(self.state_file) + logger.debug(f"Saved state atomically") + except Exception as e: + logger.error(f"Failed to save state: {e}") + + def get_next_wake_time(self) -> datetime: + """Get the next scheduled wake time""" + state = self.load() + + # Check if MCP set a specific wake time + if state.get('next_wake_timestamp'): + try: + return datetime.fromisoformat(state['next_wake_timestamp']) + except ValueError: + logger.warning("Invalid next_wake_timestamp, using interval") + + # Use interval (from MCP or default) + interval_minutes = state.get('interval_minutes', DEFAULT_INTERVAL_MINUTES) + return datetime.now() + timedelta(minutes=interval_minutes) + + def set_next_wake_time(self, wake_time: datetime): + """Set the next wake time (called after processing MCP updates)""" + state = self.load() + state['next_wake_timestamp'] = wake_time.isoformat() + state['last_wake'] = datetime.now().isoformat() + self.save(state) + + def clear_next_wake_override(self): + """Clear MCP-set wake time, reverting to interval""" + state = self.load() + state.pop('next_wake_timestamp', None) + self.save(state) + + def is_paused(self) -> bool: + """Check if automation is paused""" + state = self.load() + return state.get('paused', False) + + def has_matrix_wake_request(self) -> bool: + """Check if Matrix integration requested a wake""" + state = self.load() + return state.get('matrix_wake_requested', False) + + def get_unprocessed_matrix_count(self) -> int: + """Get count of unprocessed Matrix messages""" + state = self.load() + messages = state.get('matrix_messages', []) + return len([msg for msg in messages if not msg.get('processed', False)]) + + def get_unprocessed_invite_count(self) -> int: + """Get count of unprocessed Matrix invites""" + state = self.load() + invites = state.get('matrix_invites', []) + return len([inv for inv in invites if not inv.get('processed', False)]) + + def get_unprocessed_file_count(self) -> int: + """Get count of unprocessed Matrix files""" + state = self.load() + files = state.get('matrix_files', []) + return len([f for f in files if not f.get('processed', False)]) + + def clear_matrix_wake_request(self): + """Clear Matrix wake request flag""" + state = self.load() + state['matrix_wake_requested'] = False + self.save(state) + + def prune_old_messages(self, max_age_hours: int = 24): + """Remove old processed messages to prevent state file bloat""" + state = self.load() + cutoff = datetime.now() - timedelta(hours=max_age_hours) + + original_count = len(state.get('matrix_messages', [])) + state['matrix_messages'] = [ + msg for msg in state.get('matrix_messages', []) + if not msg.get('processed', False) or + datetime.fromisoformat(msg['timestamp']) > cutoff + ] + + # Also prune invites and files + state['matrix_invites'] = [ + inv for inv in state.get('matrix_invites', []) + if not inv.get('processed', False) or + datetime.fromisoformat(inv['timestamp']) > cutoff + ] + + state['matrix_files'] = [ + f for f in state.get('matrix_files', []) + if not f.get('processed', False) or + datetime.fromisoformat(f['timestamp']) > cutoff + ] + + pruned_count = original_count - len(state.get('matrix_messages', [])) + if pruned_count > 0: + logger.info(f"Pruned {pruned_count} old processed messages") + self.save(state) + + def _default_state(self) -> dict: + """Default state structure""" + return { + 'interval_minutes': DEFAULT_INTERVAL_MINUTES, + 'paused': False, + 'last_wake': None, + 'next_wake_timestamp': None, + 'matrix_messages': [], + 'matrix_invites': [], + 'matrix_files': [], + 'matrix_last_wake': None, + 'matrix_wake_requested': False, + } + + +def send_to_claude(message: str, retry_count: int = 0) -> bool: + """ + Send message to Claude Desktop via AppleScript automation (with CMD+R refresh). + + Args: + message: Message to send + retry_count: Current retry attempt + + Returns: + bool: True if successful + """ + try: + logger.info(f"Attempt {retry_count + 1}/{MAX_RETRIES}: Sending message (with refresh)") + + # Use Python script (includes CMD+R refresh + 10s wait) + result = subprocess.run( + [sys.executable, str(PYTHON_SCRIPT_PATH), message], + capture_output=True, + text=True, + timeout=30 # Increased to accommodate 10s refresh delay + ) + + if result.returncode == 0: + logger.info("✓ Message sent successfully") + return True + else: + logger.warning(f"Script failed: {result.stdout} {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("Message send timed out (>30s)") + return False + except FileNotFoundError: + logger.error(f"Python script not found at {PYTHON_SCRIPT_PATH}") + return False + except Exception as e: + logger.error(f"Unexpected error: {e}") + return False + + +def generate_message(matrix_messages: int = 0, matrix_invites: int = 0, matrix_files: int = 0) -> str: + """Generate the message to send to Claude""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if matrix_messages > 0 or matrix_invites > 0 or matrix_files > 0: + parts = [] + if matrix_messages > 0: + parts.append(f"{matrix_messages} new Matrix message(s)") + if matrix_files > 0: + parts.append(f"{matrix_files} file(s)") + if matrix_invites > 0: + parts.append(f"{matrix_invites} room invite(s)") + + activity = " and ".join(parts) if len(parts) <= 2 else ", ".join(parts[:-1]) + f", and {parts[-1]}" + + tools = "Use get_matrix_messages() to retrieve messages (auto-marks as processed)" + if matrix_invites > 0: + tools += ", list_matrix_invites() to see invites (auto-marks as processed), and join_matrix_room() to accept" + tools += ". Use next_wakeup() to adjust monitoring interval if needed." + + return f"[Matrix Wakeup: {timestamp} - you have {activity}. {tools}]" + else: + return ( + f"[Autonomous System Wakeup: {timestamp}]" + ) + + +def attempt_send_message(message: str) -> bool: + """Try sending message with retries""" + for attempt in range(MAX_RETRIES): + if send_to_claude(message, attempt): + return True + + if attempt < MAX_RETRIES - 1: + logger.warning(f"Retrying in {RETRY_DELAY} seconds...") + time.sleep(RETRY_DELAY) + + logger.error(f"Failed after {MAX_RETRIES} attempts") + return False + + +def signal_handler(signum, frame): + """Handle shutdown signals gracefully""" + global shutdown_flag + logger.info(f"Received signal {signum}, shutting down gracefully...") + shutdown_flag = True + + +def main_loop(): + """Main daemon loop""" + global shutdown_flag + + state = WakeupState(STATE_FILE) + + logger.info("=" * 60) + logger.info("Claude Desktop Automation Daemon Starting") + logger.info(f"State file: {STATE_FILE}") + logger.info(f"Default interval: {DEFAULT_INTERVAL_MINUTES} minutes") + logger.info("=" * 60) + + # Register signal handlers + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Track scheduled wake time separately (not affected by Matrix wakes) + scheduled_wake_time = None + + while not shutdown_flag: + try: + # Check if paused + if state.is_paused(): + logger.info("Automation is paused, sleeping for 60 seconds...") + time.sleep(60) + continue + + # Check for Matrix wake request + matrix_wake = state.has_matrix_wake_request() + matrix_count = state.get_unprocessed_matrix_count() if matrix_wake else 0 + invite_count = state.get_unprocessed_invite_count() if matrix_wake else 0 + file_count = state.get_unprocessed_file_count() if matrix_wake else 0 + + if matrix_wake: + logger.info(f"Matrix wake requested ({matrix_count} messages, {file_count} files, {invite_count} invites)") + # Clear the flag immediately so we don't wake again + state.clear_matrix_wake_request() + + # Wake up for Matrix activity + logger.info("Waking Claude for Matrix activity") + + # Generate and send message + message = generate_message(matrix_messages=matrix_count, matrix_invites=invite_count, matrix_files=file_count) + success = attempt_send_message(message) + + if success: + logger.info("Matrix wake message sent") + + # Wait grace period for Claude to process messages + grace_period = 30 + logger.info(f"Waiting {grace_period}s for Claude to process...") + time.sleep(grace_period) + + # IMPORTANT: Matrix wakes do NOT affect scheduled wake time + # Continue to next iteration (will use existing scheduled_wake_time) + logger.info(f"Matrix wake complete, next scheduled wake still at {scheduled_wake_time}") + continue + + # Calculate next scheduled wake time if needed + if scheduled_wake_time is None or datetime.now() >= scheduled_wake_time: + # Either first run or scheduled wake time has passed + scheduled_wake_time = state.get_next_wake_time() + logger.info(f"Calculated new scheduled wake time: {scheduled_wake_time}") + + next_wake = scheduled_wake_time + now = datetime.now() + + # Calculate sleep duration + if next_wake > now: + sleep_seconds = (next_wake - now).total_seconds() + logger.info(f"Next wake at {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {sleep_seconds/60:.1f} minutes)") + + # Sleep in 1-minute chunks to allow for quick shutdown and Matrix wakes + while sleep_seconds > 0 and not shutdown_flag: + # Check for Matrix wake requests during sleep + if state.has_matrix_wake_request(): + logger.info("Matrix wake request detected during sleep, breaking early") + break + + chunk = min(60, sleep_seconds) + time.sleep(chunk) + sleep_seconds -= chunk + + if shutdown_flag: + break + + # If we broke early due to Matrix wake, continue to handle it + if state.has_matrix_wake_request(): + continue + + # Wake up! + logger.info("Wake time reached, sending message to Claude") + + # Generate and send message + message = generate_message() + success = attempt_send_message(message) + + if success: + logger.info("Message sent, Claude may adjust next wake time via MCP") + + # Wait a grace period for MCP to update state (Claude might call next_wakeup()) + grace_period = 30 # seconds + logger.info(f"Waiting {grace_period}s for potential MCP updates...") + time.sleep(grace_period) + + # Check if MCP updated the state + updated_state = state.load() + if updated_state.get('next_wake_timestamp'): + logger.info(f"MCP set custom wake time: {updated_state['next_wake_timestamp']}") + else: + logger.info(f"No MCP override, using default interval: {DEFAULT_INTERVAL_MINUTES} min") + + else: + logger.warning("Message send failed, will retry next cycle") + + # Clear the specific timestamp override (if it was used) + state.clear_next_wake_override() + + # Prune old processed messages to prevent state file bloat + state.prune_old_messages(max_age_hours=24) + + # Reset scheduled_wake_time so it recalculates on next iteration + # This allows MCP updates to take effect + scheduled_wake_time = None + + except Exception as e: + logger.exception(f"Error in main loop: {e}") + # Sleep a bit before retrying to avoid tight error loops + time.sleep(60) + + logger.info("Daemon shutting down") + + +if __name__ == "__main__": + try: + main_loop() + except KeyboardInterrupt: + logger.info("Interrupted by user") + sys.exit(0) + except Exception as e: + logger.exception(f"Fatal error: {e}") + sys.exit(1) diff --git a/automation_daemon_v2.py b/automation_daemon_v2.py new file mode 100644 index 0000000..407ac9e --- /dev/null +++ b/automation_daemon_v2.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" +Claude Desktop Automation Daemon + +Continuously running daemon that sends periodic messages to Claude Desktop. +Wake interval can be controlled via the companion MCP server. + +Features: +- Polling-based timer loop (checks every 60 seconds if wake time has passed) +- Dynamic wake times via MCP control (next_wakeup() can be called anytime) +- Matrix integration: wakes Claude when Matrix messages arrive +- Shared state file for IPC with MCP and Matrix monitor +- Defaults to 60 minutes if no MCP override +- Auto-recovery and error handling + +v2.0 - Polling approach (Dec 2025) +- Removed 30-second grace period +- Now polls every 60s to check if wake time passed +- Claude can call next_wakeup() at any point, not just within grace window +""" + +import json +import logging +import subprocess +import time +import signal +import sys +import fcntl +import os +from datetime import datetime, timedelta +from pathlib import Path +from threading import Lock + +# Configuration +SCRIPT_DIR = Path(__file__).parent +PYTHON_SCRIPT_PATH = SCRIPT_DIR / "send_to_claude.py" +APPLESCRIPT_PATH = SCRIPT_DIR / "send_to_claude.scpt" # Kept as fallback +STATE_FILE = Path.home() / ".claude-automation-state.json" +LOG_FILE = Path("/tmp/claude-automation-daemon.log") + +DEFAULT_INTERVAL_MINUTES = 60 +POLL_INTERVAL_SECONDS = 60 # How often to check if wake time has passed +MAX_RETRIES = 3 +RETRY_DELAY = 2 # seconds + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Global state +shutdown_flag = False + + +class WakeupState: + """Manages shared state with MCP server""" + + def __init__(self, state_file: Path): + self.state_file = state_file + self.lock = Lock() + + def load(self) -> dict: + """Load state from JSON file""" + with self.lock: + if not self.state_file.exists(): + return self._default_state() + + try: + with open(self.state_file, 'r') as f: + state = json.load(f) + logger.debug(f"Loaded state: {state}") + return state + except Exception as e: + logger.error(f"Failed to load state: {e}") + return self._default_state() + + def save(self, state: dict): + """Save state to JSON file atomically with file locking""" + with self.lock: + try: + temp_path = self.state_file.with_suffix('.tmp') + with open(temp_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + temp_path.rename(self.state_file) + logger.debug(f"Saved state atomically") + except Exception as e: + logger.error(f"Failed to save state: {e}") + + def get_next_wake_time(self) -> datetime: + """Get the next scheduled wake time, or calculate default if none set""" + state = self.load() + + # Check if MCP set a specific wake time + if state.get('next_wake_timestamp'): + try: + return datetime.fromisoformat(state['next_wake_timestamp']) + except ValueError: + logger.warning("Invalid next_wake_timestamp, calculating default") + + # No timestamp set - calculate based on last wake + interval + interval_minutes = state.get('interval_minutes', DEFAULT_INTERVAL_MINUTES) + last_wake = state.get('last_wake') + + if last_wake: + try: + last_wake_dt = datetime.fromisoformat(last_wake) + return last_wake_dt + timedelta(minutes=interval_minutes) + except ValueError: + pass + + # No last wake either - wake immediately + return datetime.now() + + def set_last_wake(self): + """Record that we just woke""" + state = self.load() + state['last_wake'] = datetime.now().isoformat() + self.save(state) + + def set_next_wake_time(self, wake_time: datetime): + """Set specific next wake time""" + state = self.load() + state['next_wake_timestamp'] = wake_time.isoformat() + self.save(state) + + def clear_next_wake_override(self): + """Clear MCP-set wake time, reverting to interval-based calculation""" + state = self.load() + state.pop('next_wake_timestamp', None) + self.save(state) + + def is_paused(self) -> bool: + """Check if automation is paused""" + state = self.load() + return state.get('paused', False) + + def has_matrix_wake_request(self) -> bool: + """Check if Matrix integration requested a wake""" + state = self.load() + return state.get('matrix_wake_requested', False) + + def get_unprocessed_matrix_count(self) -> int: + """Get count of unprocessed Matrix messages""" + state = self.load() + messages = state.get('matrix_messages', []) + return len([msg for msg in messages if not msg.get('processed', False)]) + + def get_unprocessed_invite_count(self) -> int: + """Get count of unprocessed Matrix invites""" + state = self.load() + invites = state.get('matrix_invites', []) + return len([inv for inv in invites if not inv.get('processed', False)]) + + def get_unprocessed_file_count(self) -> int: + """Get count of unprocessed Matrix files""" + state = self.load() + files = state.get('matrix_files', []) + return len([f for f in files if not f.get('processed', False)]) + + def clear_matrix_wake_request(self): + """Clear Matrix wake request flag""" + state = self.load() + state['matrix_wake_requested'] = False + self.save(state) + + def prune_old_messages(self, max_age_hours: int = 24): + """Remove old processed messages to prevent state file bloat""" + state = self.load() + cutoff = datetime.now() - timedelta(hours=max_age_hours) + + original_count = len(state.get('matrix_messages', [])) + state['matrix_messages'] = [ + msg for msg in state.get('matrix_messages', []) + if not msg.get('processed', False) or + datetime.fromisoformat(msg['timestamp']) > cutoff + ] + + state['matrix_invites'] = [ + inv for inv in state.get('matrix_invites', []) + if not inv.get('processed', False) or + datetime.fromisoformat(inv['timestamp']) > cutoff + ] + + state['matrix_files'] = [ + f for f in state.get('matrix_files', []) + if not f.get('processed', False) or + datetime.fromisoformat(f['timestamp']) > cutoff + ] + + pruned_count = original_count - len(state.get('matrix_messages', [])) + if pruned_count > 0: + logger.info(f"Pruned {pruned_count} old processed messages") + self.save(state) + + def _default_state(self) -> dict: + """Default state structure""" + return { + 'interval_minutes': DEFAULT_INTERVAL_MINUTES, + 'paused': False, + 'last_wake': None, + 'next_wake_timestamp': None, + 'matrix_messages': [], + 'matrix_invites': [], + 'matrix_files': [], + 'matrix_last_wake': None, + 'matrix_wake_requested': False, + } + + +def send_to_claude(message: str, retry_count: int = 0) -> bool: + """ + Send message to Claude Desktop via Python script (includes CMD+R refresh). + + Args: + message: Message to send + retry_count: Current retry attempt + + Returns: + bool: True if successful + """ + try: + logger.info(f"Attempt {retry_count + 1}/{MAX_RETRIES}: Sending message") + + result = subprocess.run( + [sys.executable, str(PYTHON_SCRIPT_PATH), message], + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode == 0: + logger.info("✓ Message sent successfully") + return True + else: + logger.warning(f"Script failed: {result.stdout} {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("Message send timed out (>30s)") + return False + except FileNotFoundError: + logger.error(f"Python script not found at {PYTHON_SCRIPT_PATH}") + return False + except Exception as e: + logger.error(f"Unexpected error: {e}") + return False + + +def generate_message(matrix_messages: int = 0, matrix_invites: int = 0, matrix_files: int = 0) -> str: + """Generate the message to send to Claude""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if matrix_messages > 0 or matrix_invites > 0 or matrix_files > 0: + parts = [] + if matrix_messages > 0: + parts.append(f"{matrix_messages} new Matrix message(s)") + if matrix_files > 0: + parts.append(f"{matrix_files} file(s)") + if matrix_invites > 0: + parts.append(f"{matrix_invites} room invite(s)") + + activity = " and ".join(parts) if len(parts) <= 2 else ", ".join(parts[:-1]) + f", and {parts[-1]}" + + tools = "Use get_matrix_messages() to retrieve messages (auto-marks as processed)" + if matrix_invites > 0: + tools += ", list_matrix_invites() to see invites (auto-marks as processed), and join_matrix_room() to accept" + tools += ". Use next_wakeup() to adjust monitoring interval if needed." + + return f"[Matrix Wakeup: {timestamp} - you have {activity}. {tools}]" + else: + return f"[Autonomous System Wakeup: {timestamp}]" + + +def attempt_send_message(message: str) -> bool: + """Try sending message with retries""" + for attempt in range(MAX_RETRIES): + if send_to_claude(message, attempt): + return True + + if attempt < MAX_RETRIES - 1: + logger.warning(f"Retrying in {RETRY_DELAY} seconds...") + time.sleep(RETRY_DELAY) + + logger.error(f"Failed after {MAX_RETRIES} attempts") + return False + + +def signal_handler(signum, frame): + """Handle shutdown signals gracefully""" + global shutdown_flag + logger.info(f"Received signal {signum}, shutting down gracefully...") + shutdown_flag = True + + +def main_loop(): + """Main daemon loop - polling approach""" + global shutdown_flag + + state = WakeupState(STATE_FILE) + + logger.info("=" * 60) + logger.info("Claude Desktop Automation Daemon Starting (v2.0 - Polling)") + logger.info(f"State file: {STATE_FILE}") + logger.info(f"Poll interval: {POLL_INTERVAL_SECONDS} seconds") + logger.info(f"Default wake interval: {DEFAULT_INTERVAL_MINUTES} minutes") + logger.info("=" * 60) + + # Register signal handlers + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + while not shutdown_flag: + try: + # Check if paused + if state.is_paused(): + logger.debug("Automation paused, sleeping...") + time.sleep(POLL_INTERVAL_SECONDS) + continue + + # Check for Matrix wake request (priority) + if state.has_matrix_wake_request(): + matrix_count = state.get_unprocessed_matrix_count() + invite_count = state.get_unprocessed_invite_count() + file_count = state.get_unprocessed_file_count() + + logger.info(f"Matrix wake requested ({matrix_count} messages, {file_count} files, {invite_count} invites)") + state.clear_matrix_wake_request() + + message = generate_message( + matrix_messages=matrix_count, + matrix_invites=invite_count, + matrix_files=file_count + ) + + if attempt_send_message(message): + logger.info("Matrix wake message sent") + + # Continue to next poll - don't affect scheduled wakes + time.sleep(POLL_INTERVAL_SECONDS) + continue + + # Check if scheduled wake time has passed + next_wake = state.get_next_wake_time() + now = datetime.now() + + if now >= next_wake: + # WAKE TIME! + logger.info(f"Wake time reached ({next_wake.strftime('%H:%M:%S')}), sending message") + + message = generate_message() + success = attempt_send_message(message) + + if success: + # Record that we woke + state.set_last_wake() + + # Clear specific timestamp override (if any) + # Next wake will be calculated from last_wake + interval + # UNLESS Claude calls next_wakeup() to set a specific time + state.clear_next_wake_override() + + logger.info("Wake complete. Claude can call next_wakeup() anytime to set next wake.") + else: + logger.warning("Wake message failed, will retry next poll") + + # Prune old messages periodically + state.prune_old_messages(max_age_hours=24) + else: + # Not time yet + time_until = (next_wake - now).total_seconds() + logger.debug(f"Next wake in {time_until/60:.1f} minutes") + + # Sleep until next poll + time.sleep(POLL_INTERVAL_SECONDS) + + except Exception as e: + logger.exception(f"Error in main loop: {e}") + time.sleep(POLL_INTERVAL_SECONDS) + + logger.info("Daemon shutting down") + + +if __name__ == "__main__": + try: + main_loop() + except KeyboardInterrupt: + logger.info("Interrupted by user") + sys.exit(0) + except Exception as e: + logger.exception(f"Fatal error: {e}") + sys.exit(1) diff --git a/com.claude.monitor.plist b/com.claude.monitor.plist new file mode 100755 index 0000000..58065cd --- /dev/null +++ b/com.claude.monitor.plist @@ -0,0 +1,51 @@ + + + + + + Label + com.claude.monitor + + + ProgramArguments + + /usr/bin/python3 + /Users/alex/scripts/claude-desktop-automation/automation_daemon.py + + + + RunAtLoad + + + + KeepAlive + + + + StandardOutPath + /tmp/claude-monitor.out + StandardErrorPath + /tmp/claude-monitor.err + + + WorkingDirectory + /Users/alex/scripts/claude-desktop-automation + + + LimitLoadToSessionType + Aqua + + + ThrottleInterval + 10 + + + + + diff --git a/matrix_integration.py b/matrix_integration.py new file mode 100755 index 0000000..2f9d43f --- /dev/null +++ b/matrix_integration.py @@ -0,0 +1,780 @@ +#!/usr/bin/env python3 +""" +Matrix Integration for Claude Desktop Automation + +Monitors Matrix rooms for messages and images, queuing them for Claude to process. +Integrates with automation_daemon.py to trigger Claude wakes when new messages arrive. + +Features: +- Async Matrix sync loop using matrix-nio +- Message and image queuing to shared state file +- 2-minute rate limiting between Matrix-triggered wakes +- Room whitelist for selective monitoring +- Image download and compression (reuses patterns from image-watch-mcp) +- Secure credential management +""" + +import asyncio +import json +import logging +import fcntl +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, List, Dict, Any +from io import BytesIO +import hashlib + +try: + from nio import ( + AsyncClient, + MatrixRoom, + RoomMessageText, + RoomMessageImage, + RoomMessageFile, + RoomMessageAudio, + InviteEvent, + LoginResponse, + SyncResponse, + DownloadResponse, + DownloadError, + ) +except ImportError: + raise ImportError( + "matrix-nio not installed. Install with: pip3 install matrix-nio\n" + "Note: Without [e2e] extra, only unencrypted rooms are supported" + ) + +try: + from PIL import Image +except ImportError: + raise ImportError("Pillow not installed. Install with: pip3 install Pillow") + +# Configuration +STATE_FILE = Path.home() / ".claude-automation-state.json" +CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json" +MATRIX_DATA_DIR = Path.home() / ".matrix-data" +LOG_FILE = Path("/tmp/matrix-integration.log") + +# Matrix wake rate limiting +MIN_MATRIX_WAKE_INTERVAL_SECONDS = 120 # 2 minutes + +# Image compression (same as image-watch-mcp) +MAX_IMAGE_SIZE_MB = 1.0 # Target for base64-encoded size (checked directly, not estimated) + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +class MatrixCredentials: + """Manages Matrix credentials and configuration""" + + def __init__(self, credentials_file: Path): + self.credentials_file = credentials_file + + def load(self) -> Dict[str, Any]: + """Load credentials from JSON file""" + if not self.credentials_file.exists(): + raise FileNotFoundError( + f"Credentials file not found: {self.credentials_file}\n" + f"Run setup_matrix.sh to create credentials" + ) + + try: + with open(self.credentials_file, 'r') as f: + creds = json.load(f) + + # Validate required fields + required = ['homeserver', 'user_id', 'access_token', 'device_id'] + missing = [field for field in required if field not in creds] + if missing: + raise ValueError(f"Missing required credentials: {missing}") + + logger.info(f"Loaded credentials for {creds['user_id']}") + return creds + + except Exception as e: + logger.error(f"Failed to load credentials: {e}") + raise + + def save(self, homeserver: str, user_id: str, access_token: str, device_id: str): + """Save credentials to JSON file (chmod 600)""" + try: + creds = { + 'homeserver': homeserver, + 'user_id': user_id, + 'access_token': access_token, + 'device_id': device_id, + 'room_whitelist': [], # Empty by default + } + + with open(self.credentials_file, 'w') as f: + json.dump(creds, f, indent=2) + + # Secure permissions + self.credentials_file.chmod(0o600) + + logger.info(f"Saved credentials for {user_id}") + + except Exception as e: + logger.error(f"Failed to save credentials: {e}") + raise + + +class MatrixState: + """Manages Matrix state in shared automation state file""" + + def __init__(self, state_file: Path): + self.state_file = state_file + + def load(self) -> Dict[str, Any]: + """Load full automation state""" + if not self.state_file.exists(): + return self._default_state() + + try: + with open(self.state_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load state: {e}") + return self._default_state() + + def save(self, state: Dict[str, Any]): + """Save full automation state atomically with file locking""" + try: + temp_path = self.state_file.with_suffix('.tmp') + with open(temp_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + temp_path.rename(self.state_file) + except Exception as e: + logger.error(f"Failed to save state: {e}") + + def add_message(self, room_id: str, room_name: str, sender: str, + message: str, timestamp: datetime, event_id: str): + """Add a text message to the queue""" + state = self.load() + + if 'matrix_messages' not in state: + state['matrix_messages'] = [] + + state['matrix_messages'].append({ + 'type': 'text', + 'room_id': room_id, + 'room_name': room_name, + 'sender': sender, + 'message': message, + 'timestamp': timestamp.isoformat(), + 'event_id': event_id, + 'processed': False, + }) + + self.save(state) + logger.info(f"Queued text message from {sender} in {room_name}") + + def add_image(self, room_id: str, room_name: str, sender: str, + image_data: bytes, filename: str, timestamp: datetime, event_id: str): + """Add an image message to the queue (base64 encoded)""" + import base64 + + state = self.load() + + if 'matrix_messages' not in state: + state['matrix_messages'] = [] + + # Encode image to base64 + image_b64 = base64.b64encode(image_data).decode('utf-8') + + state['matrix_messages'].append({ + 'type': 'image', + 'room_id': room_id, + 'room_name': room_name, + 'sender': sender, + 'filename': filename, + 'image_data': image_b64, + 'timestamp': timestamp.isoformat(), + 'event_id': event_id, + 'processed': False, + }) + + self.save(state) + logger.info(f"Queued image from {sender} in {room_name}: {filename}") + + def add_audio(self, room_id: str, room_name: str, sender: str, + audio_data: bytes, filename: str, timestamp: datetime, event_id: str): + """Add an audio/voice message to the queue (base64 encoded)""" + import base64 + + state = self.load() + + if 'matrix_messages' not in state: + state['matrix_messages'] = [] + + # Encode audio to base64 + audio_b64 = base64.b64encode(audio_data).decode('utf-8') + + state['matrix_messages'].append({ + 'type': 'audio', + 'room_id': room_id, + 'room_name': room_name, + 'sender': sender, + 'filename': filename, + 'audio_data': audio_b64, + 'timestamp': timestamp.isoformat(), + 'event_id': event_id, + 'processed': False, + }) + + self.save(state) + logger.info(f"Queued audio from {sender} in {room_name}: {filename}") + + def add_invite(self, room_id: str, room_name: str, inviter: str, timestamp: datetime): + """Add a room invite to the queue""" + state = self.load() + + if 'matrix_invites' not in state: + state['matrix_invites'] = [] + + # Check if invite already queued + for invite in state['matrix_invites']: + if invite['room_id'] == room_id and not invite.get('processed', False): + logger.debug(f"Invite to {room_id} already queued") + return + + state['matrix_invites'].append({ + 'room_id': room_id, + 'room_name': room_name, + 'inviter': inviter, + 'timestamp': timestamp.isoformat(), + 'processed': False, + }) + + self.save(state) + logger.info(f"Queued invite to {room_name} from {inviter}") + + def add_file(self, room_id: str, room_name: str, sender: str, + filename: str, filesize: int, mimetype: str, mxc_url: str, + timestamp: datetime, event_id: str): + """Add a file message to the queue""" + state = self.load() + + if 'matrix_files' not in state: + state['matrix_files'] = [] + + state['matrix_files'].append({ + 'room_id': room_id, + 'room_name': room_name, + 'sender': sender, + 'filename': filename, + 'filesize': filesize, + 'mimetype': mimetype, + 'mxc_url': mxc_url, + 'timestamp': timestamp.isoformat(), + 'event_id': event_id, + 'processed': False, + }) + + self.save(state) + logger.info(f"Queued file from {sender} in {room_name}: {filename}") + + def should_wake_for_matrix(self) -> bool: + """Check if enough time has passed since last Matrix wake""" + state = self.load() + + last_wake_str = state.get('matrix_last_wake') + if not last_wake_str: + return True + + try: + last_wake = datetime.fromisoformat(last_wake_str) + elapsed = (datetime.now() - last_wake).total_seconds() + return elapsed >= MIN_MATRIX_WAKE_INTERVAL_SECONDS + except ValueError: + return True + + def set_matrix_wake(self): + """Record that we triggered a Matrix wake""" + state = self.load() + state['matrix_last_wake'] = datetime.now().isoformat() + self.save(state) + logger.info("Set matrix_last_wake timestamp") + + def _default_state(self) -> Dict[str, Any]: + """Default state structure""" + return { + 'interval_minutes': 60, + 'paused': False, + 'last_wake': None, + 'next_wake_timestamp': None, + 'matrix_messages': [], + 'matrix_invites': [], + 'matrix_files': [], + 'matrix_last_wake': None, + 'matrix_wake_requested': False, + } + + +class MatrixMonitor: + """ + Monitors Matrix rooms for messages and images. + + Integrates with automation_daemon.py by: + 1. Queuing messages to shared state file + 2. Setting matrix_wake_requested flag for daemon to trigger Claude + 3. Respecting 2-minute rate limit between wakes + """ + + def __init__(self, credentials_file: Path = CREDENTIALS_FILE, + state_file: Path = STATE_FILE): + self.credentials_file = credentials_file + self.state_file = state_file + + self.creds_manager = MatrixCredentials(credentials_file) + self.state_manager = MatrixState(state_file) + + self.client: Optional[AsyncClient] = None + self.shutdown_flag = False + self.initial_sync_done = False # Skip queueing messages until initial sync completes + + # Load credentials + self.creds = self.creds_manager.load() + self.room_whitelist = set(self.creds.get('room_whitelist', [])) + + # Setup Matrix client + self._setup_client() + + def _setup_client(self): + """Initialize Matrix client with credentials""" + # Create data directory for E2EE store + MATRIX_DATA_DIR.mkdir(exist_ok=True) + + self.client = AsyncClient( + homeserver=self.creds['homeserver'], + user=self.creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session from credentials + self.client.access_token = self.creds['access_token'] + self.client.device_id = self.creds['device_id'] + self.client.user_id = self.creds['user_id'] # CRITICAL: Set user_id for session restore + + # Register event callbacks + self.client.add_event_callback(self._on_message_text, RoomMessageText) + self.client.add_event_callback(self._on_message_image, RoomMessageImage) + self.client.add_event_callback(self._on_message_audio, RoomMessageAudio) + self.client.add_event_callback(self._on_message_file, RoomMessageFile) + self.client.add_event_callback(self._on_invite, InviteEvent) + + logger.info(f"Matrix client configured for {self.creds['user_id']}") + + async def _on_message_text(self, room: MatrixRoom, event: RoomMessageText): + """Handle incoming text messages""" + # Skip messages from ourselves + logger.debug(f"Message from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}") + if event.sender == self.client.user_id: + logger.info(f"✓ Skipping own message: {event.body[:30]}... (sender={event.sender})") + return + + # Skip historical messages during initial sync + if not self.initial_sync_done: + logger.debug(f"Skipping historical message during initial sync: {event.body[:30]}...") + return + + # Check room whitelist + if self.room_whitelist and room.room_id not in self.room_whitelist: + logger.debug(f"Ignoring message from non-whitelisted room: {room.display_name}") + return + + logger.info(f"New message in {room.display_name} from {event.sender}: {event.body[:50]}...") + + # Queue message + self.state_manager.add_message( + room_id=room.room_id, + room_name=room.display_name or room.room_id, + sender=event.sender, + message=event.body, + timestamp=datetime.fromtimestamp(event.server_timestamp / 1000), + event_id=event.event_id, + ) + + # Trigger wake if rate limit allows + await self._maybe_trigger_wake() + + async def _on_message_image(self, room: MatrixRoom, event: RoomMessageImage): + """Handle incoming image messages""" + # Skip messages from ourselves + logger.debug(f"Image from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}") + if event.sender == self.client.user_id: + logger.info(f"✓ Skipping own image message (sender={event.sender})") + return + + # Skip historical messages during initial sync + if not self.initial_sync_done: + logger.debug(f"Skipping historical image during initial sync") + return + + # Check room whitelist + if self.room_whitelist and room.room_id not in self.room_whitelist: + logger.debug(f"Ignoring image from non-whitelisted room: {room.display_name}") + return + + logger.info(f"New image in {room.display_name} from {event.sender}: {event.body}") + + # Download and compress image + try: + image_data = await self._download_and_compress_image(event.url) + + if image_data: + self.state_manager.add_image( + room_id=room.room_id, + room_name=room.display_name or room.room_id, + sender=event.sender, + image_data=image_data, + filename=event.body, + timestamp=datetime.fromtimestamp(event.server_timestamp / 1000), + event_id=event.event_id, + ) + + # Trigger wake if rate limit allows + await self._maybe_trigger_wake() + else: + logger.warning(f"Failed to download/compress image: {event.body}") + + except Exception as e: + logger.error(f"Error processing image: {e}") + + async def _on_message_audio(self, room: MatrixRoom, event: RoomMessageAudio): + """Handle incoming audio/voice messages""" + # Skip messages from ourselves + logger.debug(f"Audio from {event.sender}, bot user_id={self.client.user_id}") + if event.sender == self.client.user_id: + logger.info(f"✓ Skipping own audio message (sender={event.sender})") + return + + # Skip historical messages during initial sync + if not self.initial_sync_done: + logger.debug(f"Skipping historical audio during initial sync") + return + + # Check room whitelist + if self.room_whitelist and room.room_id not in self.room_whitelist: + logger.debug(f"Ignoring audio from non-whitelisted room: {room.display_name}") + return + + logger.info(f"New audio in {room.display_name} from {event.sender}: {event.body}") + + # Download audio file + try: + response = await self.client.download(event.url) + + if isinstance(response, DownloadError): + logger.error(f"Audio download failed: {response.message}") + return + + # Queue audio message (similar to image, but type="audio") + self.state_manager.add_audio( + room_id=room.room_id, + room_name=room.display_name or room.room_id, + sender=event.sender, + audio_data=response.body, + filename=event.body, + timestamp=datetime.fromtimestamp(event.server_timestamp / 1000), + event_id=event.event_id, + ) + + # Trigger wake if rate limit allows + await self._maybe_trigger_wake() + + except Exception as e: + logger.error(f"Error processing audio: {e}") + + async def _download_and_compress_image(self, mxc_url: str) -> Optional[bytes]: + """ + Download and compress Matrix image to fit Claude's 1MB limit. + + Reuses compression logic from image-watch-mcp. + """ + try: + # Download image from Matrix + response = await self.client.download(mxc_url) + + if isinstance(response, DownloadError): + logger.error(f"Download failed: {response.message}") + return None + + # Compress image + image_bytes = await self._smart_compress(response.body) + return image_bytes + + except Exception as e: + logger.error(f"Failed to download image from {mxc_url}: {e}") + return None + + async def _smart_compress(self, image_data: bytes, + target_size_mb: float = MAX_IMAGE_SIZE_MB) -> bytes: + """ + Compress image to fit under target size with quality adaptation. + + Args: + image_data: Raw image bytes + target_size_mb: Target size in MB for base64-encoded output (default 1.0MB) + + Returns: + Compressed JPEG bytes that will be under target when base64 encoded + + NOTE: We check the ACTUAL base64 size, not an estimate. + """ + try: + import base64 + + # Load image + img = Image.open(BytesIO(image_data)) + + # Convert RGBA to RGB if needed + if img.mode == 'RGBA': + background = Image.new('RGB', img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[3]) + img = background + elif img.mode not in ('RGB', 'L'): + img = img.convert('RGB') + + # Try progressive quality reduction + for quality in [85, 75, 65, 55]: + buffer = BytesIO() + img.save(buffer, format='JPEG', quality=quality, optimize=True) + img_bytes = buffer.getvalue() + + # Check ACTUAL base64 size instead of estimating + base64_encoded = base64.b64encode(img_bytes) + base64_size_mb = len(base64_encoded) / (1024 * 1024) + raw_size_mb = len(img_bytes) / (1024 * 1024) + + if base64_size_mb <= target_size_mb: + logger.info( + f"✓ Compressed to {img.width}x{img.height} @ quality {quality}: " + f"{raw_size_mb:.2f}MB raw → {base64_size_mb:.2f}MB base64 (actual)" + ) + return img_bytes + + # If still too large, resize + logger.warning(f"Quality reduction insufficient, resizing from {img.width}x{img.height}") + + for scale in [0.75, 0.5, 0.25]: + new_width = int(img.width * scale) + new_height = int(img.height * scale) + resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + buffer = BytesIO() + resized.save(buffer, format='JPEG', quality=75, optimize=True) + img_bytes = buffer.getvalue() + + # Check ACTUAL base64 size instead of estimating + base64_encoded = base64.b64encode(img_bytes) + base64_size_mb = len(base64_encoded) / (1024 * 1024) + raw_size_mb = len(img_bytes) / (1024 * 1024) + + if base64_size_mb <= target_size_mb: + logger.info( + f"✓ Resized to {new_width}x{new_height}: " + f"{raw_size_mb:.2f}MB raw → {base64_size_mb:.2f}MB base64 (actual)" + ) + return img_bytes + + # Last resort: use heavily compressed version + logger.warning("Could not compress to target, using minimum quality") + buffer = BytesIO() + final_img = img.resize((800, 600), Image.Resampling.LANCZOS) + final_img.save(buffer, format='JPEG', quality=50, optimize=True) + return buffer.getvalue() + + except Exception as e: + logger.error(f"Compression failed: {e}") + raise + + async def _on_message_file(self, room: MatrixRoom, event: RoomMessageFile): + """Handle incoming file messages""" + # Skip messages from ourselves + logger.debug(f"File from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}") + if event.sender == self.client.user_id: + logger.info(f"✓ Skipping own file message (sender={event.sender})") + return + + # Skip historical messages during initial sync + if not self.initial_sync_done: + logger.debug(f"Skipping historical file during initial sync") + return + + # Check room whitelist + if self.room_whitelist and room.room_id not in self.room_whitelist: + logger.debug(f"Ignoring file from non-whitelisted room: {room.display_name}") + return + + logger.info(f"New file in {room.display_name} from {event.sender}: {event.body}") + + # Queue file metadata (don't download yet) + self.state_manager.add_file( + room_id=room.room_id, + room_name=room.display_name or room.room_id, + sender=event.sender, + filename=event.body, + filesize=event.source.get('content', {}).get('info', {}).get('size', 0), + mimetype=event.source.get('content', {}).get('info', {}).get('mimetype', 'application/octet-stream'), + mxc_url=event.url, + timestamp=datetime.fromtimestamp(event.server_timestamp / 1000), + event_id=event.event_id, + ) + + # Trigger wake if rate limit allows + await self._maybe_trigger_wake() + + async def _on_invite(self, room: MatrixRoom, event: InviteEvent): + """Handle room invites""" + # Skip historical invites during initial sync + if not self.initial_sync_done: + logger.debug(f"Skipping historical invite during initial sync") + return + + logger.info(f"Received invite to {room.display_name or room.room_id} from {event.sender}") + + # Queue invite - InviteEvent doesn't have server_timestamp, use current time + self.state_manager.add_invite( + room_id=room.room_id, + room_name=room.display_name or room.room_id, + inviter=event.sender, + timestamp=datetime.now(), + ) + + # Trigger wake if rate limit allows + await self._maybe_trigger_wake() + + async def _maybe_trigger_wake(self): + """Trigger Claude wake if rate limit allows""" + if self.state_manager.should_wake_for_matrix(): + logger.info("Rate limit OK, triggering Claude wake for Matrix activity") + + # Set typing indicators in rooms with unprocessed messages + await self._set_typing_indicators() + + self.state_manager.set_matrix_wake() + + # Set flag that automation_daemon.py will check + state = self.state_manager.load() + state['matrix_wake_requested'] = True + self.state_manager.save(state) + else: + logger.info("Rate limit active, queuing activity without immediate wake") + + async def _set_typing_indicators(self): + """Set typing indicators in rooms with unprocessed messages""" + try: + state = self.state_manager.load() + + # Get all rooms with unprocessed activity + rooms_with_activity = set() + + # Check messages + for msg in state.get('matrix_messages', []): + if not msg.get('processed', False): + rooms_with_activity.add(msg['room_id']) + + # Check invites + for inv in state.get('matrix_invites', []): + if not inv.get('processed', False): + rooms_with_activity.add(inv['room_id']) + + # Check files + for file in state.get('matrix_files', []): + if not file.get('processed', False): + rooms_with_activity.add(file['room_id']) + + # Set typing indicator for each room (30 seconds) + for room_id in rooms_with_activity: + try: + await self.client.room_typing(room_id, typing_state=True, timeout=30000) + logger.debug(f"Set typing indicator in {room_id}") + except Exception as e: + logger.warning(f"Failed to set typing in {room_id}: {e}") + + if rooms_with_activity: + logger.info(f"Set typing indicators in {len(rooms_with_activity)} rooms") + + except Exception as e: + logger.error(f"Failed to set typing indicators: {e}") + + async def sync_loop(self): + """Main sync loop - runs continuously""" + logger.info("=" * 60) + logger.info("Matrix Monitor Starting") + logger.info(f"User: {self.creds['user_id']}") + logger.info(f"Homeserver: {self.creds['homeserver']}") + logger.info(f"Room whitelist: {self.room_whitelist or 'All rooms'}") + logger.info("=" * 60) + + try: + # Initial sync + logger.info("Performing initial sync (historical messages will be ignored)...") + await self.client.sync(timeout=30000, full_state=True) + self.initial_sync_done = True # Now start queueing new messages + logger.info(f"Initial sync complete - now monitoring for NEW messages only") + logger.info(f"Bot user ID: {self.client.user_id} - messages from this user will be SKIPPED") + + # Continuous sync loop + while not self.shutdown_flag: + try: + response = await self.client.sync(timeout=30000) + + if isinstance(response, SyncResponse): + # Sync successful, callbacks already fired + # Check if we have queued messages that can now trigger a wake + # (in case messages arrived during cooldown period) + state = self.state_manager.load() + unprocessed_messages = [m for m in state.get('matrix_messages', []) if not m.get('processed', False)] + unprocessed_invites = [i for i in state.get('matrix_invites', []) if not i.get('processed', False)] + + if (unprocessed_messages or unprocessed_invites) and not state.get('matrix_wake_requested', False): + # We have queued items and no wake pending, check if rate limit allows wake now + await self._maybe_trigger_wake() + else: + logger.warning(f"Sync returned unexpected type: {type(response)}") + + except Exception as e: + logger.error(f"Error in sync loop: {e}") + await asyncio.sleep(10) # Back off on errors + + except Exception as e: + logger.exception(f"Fatal error in sync loop: {e}") + + finally: + logger.info("Matrix monitor shutting down") + await self.client.close() + + def shutdown(self): + """Request graceful shutdown""" + logger.info("Shutdown requested") + self.shutdown_flag = True + + +async def main(): + """Main entry point for testing""" + monitor = MatrixMonitor() + + try: + await monitor.sync_loop() + except KeyboardInterrupt: + logger.info("Interrupted by user") + monitor.shutdown() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/matrix_mcp.py b/matrix_mcp.py new file mode 100755 index 0000000..c010c25 --- /dev/null +++ b/matrix_mcp.py @@ -0,0 +1,1853 @@ +#!/usr/bin/env python3 +""" +Matrix Control MCP Server + +Model Context Protocol server that allows Claude to interact with Matrix messages +queued by the Matrix integration. + +Main Tools: +- get_matrix_messages() - Retrieve queued messages (text + images) +- get_matrix_image(event_id) - Retrieve a Matrix image by event ID +- matrix_mark_messages_processed(event_ids) - Mark messages as processed +- matrix_send_message(room_id, message) - Send text message with markdown formatting +- matrix_send_emote(room_id, action) - Send emote/action (like /me) +- matrix_send_image(room_id, file_path) - Send image file to a Matrix room +- matrix_send_voice(room_id, file_path) - Send voice message (audio file) to a Matrix room +- get_matrix_status() - Check Matrix integration status +- list_matrix_rooms() - List available Matrix rooms +- list_matrix_invites() - List room invites +- join_matrix_room(room_id) - Join a Matrix room +- matrix_reply_to_message(room_id, event_id, message) - Reply to a message +- matrix_react_to_message(room_id, event_id, emoji) - React with emoji +""" + +import json +import logging +import asyncio +import fcntl +import os +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Dict, Any + +from fastmcp import FastMCP +from fastmcp.utilities.types import Image as MCPImage + +try: + from nio import ( + AsyncClient, + RoomSendError, + RoomSendResponse, + UploadResponse, + UploadError, + JoinResponse, + JoinError, + RoomLeaveResponse, + RoomLeaveError, + RoomGetStateResponse, + RoomGetStateError, + RoomMessagesResponse, + RoomMessagesError, + ProfileGetResponse, + ProfileGetError, + ProfileGetDisplayNameResponse, + ProfileGetAvatarResponse, + DownloadResponse, + DownloadError, + RoomMemberEvent, + ) +except ImportError: + raise ImportError( + "matrix-nio not installed. Install with: pip3 install matrix-nio\n" + "Note: Without [e2e] extra, only unencrypted rooms are supported" + ) + +# Configuration +STATE_FILE = Path.home() / ".claude-automation-state.json" +CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json" +MATRIX_DATA_DIR = Path.home() / ".matrix-data" +LOG_FILE = Path("/tmp/matrix-mcp.log") + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Initialize MCP server +mcp = FastMCP("Matrix Control") + + +def load_state() -> dict: + """Load automation state from JSON file""" + if not STATE_FILE.exists(): + return { + 'interval_minutes': 60, + 'paused': False, + 'last_wake': None, + 'next_wake_timestamp': None, + 'matrix_messages': [], + 'matrix_invites': [], + 'matrix_last_wake': None, + 'matrix_wake_requested': False, + } + + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load state: {e}") + return {} + + +def save_state(state: dict): + """Save automation state to JSON file atomically with file locking""" + try: + temp_path = STATE_FILE.with_suffix('.tmp') + with open(temp_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + temp_path.rename(STATE_FILE) + logger.info(f"Saved state atomically") + except Exception as e: + logger.error(f"Failed to save state: {e}") + + +def load_credentials() -> dict: + """Load Matrix credentials""" + if not CREDENTIALS_FILE.exists(): + raise FileNotFoundError( + f"Credentials file not found: {CREDENTIALS_FILE}\n" + f"Run setup_matrix.sh to create credentials" + ) + + try: + with open(CREDENTIALS_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load credentials: {e}") + raise + + +@mcp.tool() +def get_matrix_messages( + limit: int = 10, + include_processed: bool = False +) -> List[Dict[str, Any]]: + """ + Retrieve queued Matrix messages (metadata only). + + **Messages are automatically marked as processed when retrieved!** + + Returns metadata for both text messages and images. For images, use + get_matrix_image(event_id) to retrieve the actual image. + + Args: + limit: Maximum number of messages to return (default: 10) + include_processed: Include already-processed messages (default: False) + + Returns: + List of messages with structure: + + Text messages: + - type: "text" + - room_id: Matrix room ID + - room_name: Human-readable room name + - sender: Message sender's Matrix ID + - message: Message text + - timestamp: ISO timestamp + - event_id: Matrix event ID + - processed: True (auto-marked when retrieved) + + Image messages (metadata only): + - type: "image" + - room_id: Matrix room ID + - room_name: Human-readable room name + - sender: Message sender's Matrix ID + - filename: Image filename + - timestamp: ISO timestamp + - event_id: Matrix event ID (use with get_matrix_image()) + - processed: True (auto-marked when retrieved) + + Examples: + get_matrix_messages() - Get up to 10 unprocessed messages (auto-marks as processed) + get_matrix_messages(limit=50) - Get up to 50 unprocessed messages + get_matrix_messages(include_processed=True) - Include already-processed messages + + # For images: + messages = get_matrix_messages() + for msg in messages: + if msg['type'] == 'image': + image = get_matrix_image(msg['event_id']) # Fetches actual image + """ + try: + state = load_state() + messages = state.get('matrix_messages', []) + logger.debug(f"Total messages in state: {len(messages)}") + + # Filter by processed status + if not include_processed: + unprocessed_before = [msg for msg in messages if not msg.get('processed', False)] + logger.debug(f"Unprocessed messages before filtering: {len(unprocessed_before)}") + messages = unprocessed_before + + # Limit results + messages = messages[:limit] + logger.debug(f"Messages after limit: {len(messages)}") + + # Automatically mark these messages as processed (they're being retrieved) + event_ids_to_mark = [msg['event_id'] for msg in messages] + if event_ids_to_mark: + logger.debug(f"About to mark {len(event_ids_to_mark)} messages as processed") + marked_count = _mark_messages_processed_internal(event_ids_to_mark) + logger.info(f"Auto-marked {marked_count} messages as processed") + else: + logger.debug("No messages to mark as processed") + + # Format messages for return (metadata only) + formatted_messages = [] + for msg in messages: + if msg['type'] == 'image': + # Return metadata only - use get_matrix_image(event_id) to fetch actual image + formatted_msg = { + 'type': 'image', + 'room_id': msg['room_id'], + 'room_name': msg['room_name'], + 'sender': msg['sender'], + 'filename': msg['filename'], + 'timestamp': msg['timestamp'], + 'event_id': msg['event_id'], + 'processed': True, # Now marked as processed + } + elif msg['type'] == 'audio': + # Return metadata only - use get_matrix_audio(event_id) to fetch actual audio + formatted_msg = { + 'type': 'audio', + 'room_id': msg['room_id'], + 'room_name': msg['room_name'], + 'sender': msg['sender'], + 'filename': msg['filename'], + 'timestamp': msg['timestamp'], + 'event_id': msg['event_id'], + 'processed': True, # Now marked as processed + } + else: + formatted_msg = msg.copy() # Make a copy to avoid modifying original + formatted_msg['processed'] = True # Now marked as processed + + formatted_messages.append(formatted_msg) + + logger.info(f"Returned {len(formatted_messages)} Matrix messages") + return formatted_messages + + except Exception as e: + logger.error(f"Failed to get Matrix messages: {e}") + return [] + + +@mcp.tool() +def get_matrix_image(event_id: str) -> MCPImage: + """ + Retrieve a Matrix image by event ID. + + Use this after get_matrix_messages() returns an image message to + fetch the actual image for inline display. + + Args: + event_id: Matrix event ID of the image message + + Returns: + MCPImage object for inline display in Claude Desktop + + Examples: + get_matrix_image("$abc123...") + """ + try: + import base64 + + state = load_state() + messages = state.get('matrix_messages', []) + + # Find the image message + for msg in messages: + if msg['event_id'] == event_id and msg['type'] == 'image': + # Decode base64 to raw bytes + image_bytes = base64.b64decode(msg['image_data']) + logger.info(f"Returning image: {msg['filename']} ({len(image_bytes)} bytes)") + # Return MCPImage object like image-watch-mcp does + return MCPImage(data=image_bytes, format="jpeg") + + logger.warning(f"Image not found for event_id: {event_id}") + raise ValueError(f"No image found with event_id: {event_id}") + + except Exception as e: + logger.error(f"Failed to get image: {e}") + raise + + +@mcp.tool() +def get_matrix_audio(event_id: str, save_path: str = None) -> str: + """ + Retrieve a Matrix audio/voice message by event ID. + + Use this after get_matrix_messages() returns an audio message. + Saves the audio to a file and returns the path. + + Args: + event_id: Matrix event ID of the audio message + save_path: Optional path to save audio (defaults to temp file) + + Returns: + Path to the saved audio file + + Examples: + audio_path = get_matrix_audio("$abc123...") + transcription = ear_transcribe(audio_path) + """ + try: + import base64 + import tempfile + from pathlib import Path + + state = load_state() + messages = state.get('matrix_messages', []) + + # Find the audio message + for msg in messages: + if msg['event_id'] == event_id and msg['type'] == 'audio': + # Decode base64 to raw bytes + audio_bytes = base64.b64decode(msg['audio_data']) + + # Determine save path + if save_path: + audio_path = Path(save_path).expanduser() + else: + # Use temp file with original extension + filename = msg.get('filename', 'voice.ogg') + ext = Path(filename).suffix or '.ogg' + temp_dir = Path(tempfile.gettempdir()) + audio_path = temp_dir / f"matrix_audio_{event_id[:8]}{ext}" + + # Save audio file + with open(audio_path, 'wb') as f: + f.write(audio_bytes) + + logger.info(f"Saved audio: {audio_path} ({len(audio_bytes)} bytes)") + return str(audio_path) + + logger.warning(f"Audio not found for event_id: {event_id}") + raise ValueError(f"No audio found with event_id: {event_id}") + + except Exception as e: + logger.error(f"Failed to get audio: {e}") + raise + + +def _mark_messages_processed_internal(event_ids: List[str]) -> int: + """ + Internal helper to mark messages as processed. + + Returns: + Number of messages marked + """ + state = load_state() + messages = state.get('matrix_messages', []) + + marked_count = 0 + for msg in messages: + if msg['event_id'] in event_ids: + msg['processed'] = True + marked_count += 1 + + state['matrix_messages'] = messages + save_state(state) + + # Clear wake request flag if all messages processed + unprocessed = [msg for msg in messages if not msg.get('processed', False)] + if not unprocessed: + state['matrix_wake_requested'] = False + save_state(state) + + return marked_count + + +@mcp.tool() +def matrix_mark_messages_processed(event_ids: List[str]) -> str: + """ + Mark Matrix messages as processed. + + After Claude has responded to messages, mark them as processed so they + won't be returned in future get_matrix_messages() calls. + + Args: + event_ids: List of Matrix event IDs to mark as processed + + Returns: + Confirmation message + + Examples: + matrix_mark_messages_processed(["$event1", "$event2"]) + """ + try: + marked_count = _mark_messages_processed_internal(event_ids) + logger.info(f"Marked {marked_count} messages as processed") + return f"✓ Marked {marked_count} messages as processed" + + except Exception as e: + logger.error(f"Failed to mark messages: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_send_image(room_id: str, file_path: str) -> str: + """ + Send an image to a Matrix room from a local file. + + Reads the image from the file system, uploads it to the Matrix homeserver, + and sends it to the specified room. Works with files from DreamTail, + image-watch, or any other local image file. + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + file_path: Path to the image file on disk + + Returns: + Confirmation message or error + + Examples: + matrix_send_image("!abc123:matrix.org", "/path/to/image.jpg") + matrix_send_image("!abc123:matrix.org", "~/Downloads/photo.png") + """ + try: + from pathlib import Path + from io import BytesIO + from PIL import Image + + # Resolve path + path = Path(file_path).expanduser().resolve() + if not path.exists(): + return f"Error: File not found: {file_path}" + + if not path.is_file(): + return f"Error: Not a file: {file_path}" + + # Read image data + try: + image_data = path.read_bytes() + except Exception as e: + return f"Error reading file: {str(e)}" + + # Get image dimensions and format + try: + img = Image.open(BytesIO(image_data)) + width, height = img.size + img_format = img.format.lower() if img.format else 'jpeg' + mime_type = f'image/{img_format}' + except Exception as e: + logger.warning(f"Could not extract image info: {e}") + width, height = None, None + mime_type = 'image/jpeg' + + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Upload image to Matrix homeserver using aiohttp directly + # (nio's upload has timeout issues) + filename = path.name + logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") + + import aiohttp + upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" + headers = { + "Authorization": f"Bearer {creds['access_token']}", + "Content-Type": mime_type, + } + + async with aiohttp.ClientSession() as session: + async with session.post(upload_url, data=image_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status == 200: + result = await resp.json() + content_uri = result.get("content_uri") + logger.info(f"Upload successful: {content_uri}") + else: + error_text = await resp.text() + await client.close() + logger.error(f"Upload failed: {resp.status} - {error_text}") + return f"Error uploading image: {resp.status} - {error_text}" + + # Send image message + content = { + "msgtype": "m.image", + "body": filename, + "url": content_uri, + "info": { + "mimetype": mime_type, + "size": len(image_data), + } + } + + if width and height: + content["info"]["w"] = width + content["info"]["h"] = height + + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent image to {room_id}: {filename}") + return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send image: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send image from file: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_send_voice(room_id: str, file_path: str) -> str: + """ + Send a voice message to a Matrix room from a local audio file. + + Reads the audio file (WAV, OGG, MP3, etc.) from the file system, uploads it + to the Matrix homeserver, and sends it as a voice message. Works with files + from voice-mcp or any other local audio file. + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + file_path: Path to the audio file on disk (e.g., "~/voice_audio/abc123.wav") + + Returns: + Confirmation message or error + + Examples: + matrix_send_voice("!abc123:matrix.org", "~/voice_audio/job123.wav") + matrix_send_voice("!abc123:matrix.org", "/tmp/recording.ogg") + """ + try: + from pathlib import Path + import wave + + # Resolve path + path = Path(file_path).expanduser().resolve() + if not path.exists(): + return f"Error: File not found: {file_path}" + + if not path.is_file(): + return f"Error: Not a file: {file_path}" + + # Read audio data + try: + audio_data = path.read_bytes() + except Exception as e: + return f"Error reading file: {str(e)}" + + # Detect audio format and get metadata + file_ext = path.suffix.lower() + + # Map file extension to MIME type + mime_type_map = { + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.opus': 'audio/opus', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.flac': 'audio/flac', + } + mime_type = mime_type_map.get(file_ext, 'audio/wav') + + # Try to get duration for WAV files + duration_ms = None + if file_ext == '.wav': + try: + with wave.open(str(path), 'rb') as wav_file: + frames = wav_file.getnframes() + rate = wav_file.getframerate() + duration_ms = int((frames / rate) * 1000) + except Exception as e: + logger.warning(f"Could not extract WAV duration: {e}") + + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Upload audio to Matrix homeserver using aiohttp directly + # (nio's upload has timeout issues) + filename = path.name + logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") + + import aiohttp + upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" + headers = { + "Authorization": f"Bearer {creds['access_token']}", + "Content-Type": mime_type, + } + + async with aiohttp.ClientSession() as session: + async with session.post(upload_url, data=audio_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status == 200: + result = await resp.json() + content_uri = result.get("content_uri") + logger.info(f"Upload successful: {content_uri}") + else: + error_text = await resp.text() + await client.close() + logger.error(f"Upload failed: {resp.status} - {error_text}") + return f"Error uploading audio: {resp.status} - {error_text}" + + # Send voice message + content = { + "msgtype": "m.audio", + "body": filename, + "url": content_uri, + "info": { + "mimetype": mime_type, + "size": len(audio_data), + } + } + + # Add duration if we extracted it + if duration_ms: + content["info"]["duration"] = duration_ms + + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent voice message to {room_id}: {filename}") + return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send voice message: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send voice message from file: {e}") + return f"Error: {str(e)}" + + +def _convert_markdown_to_html(text: str) -> str: + """ + Convert basic markdown formatting to Matrix HTML. + + Supports: + - **bold** → bold + - *italic* → italic + - ***bold italic*** → bold italic + """ + import re + + # Convert ***bold italic*** first (three asterisks) + text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text) + + # Convert **bold** (two asterisks) + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + + # Convert *italic* (single asterisk) + text = re.sub(r'\*(.+?)\*', r'\1', text) + + return text + + +@mcp.tool() +async def matrix_send_message(room_id: str, message: str) -> str: + """ + Send a message to a Matrix room. + + Supports markdown-style formatting (*italic*, **bold**). + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + message: Text message to send (supports *italic*, **bold**) + + Returns: + Confirmation message or error + + Examples: + matrix_send_message("!abc123:matrix.org", "Hello from Claude!") + matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Convert markdown to HTML + html_body = _convert_markdown_to_html(message) + + # Build message content + content = { + "msgtype": "m.text", + "body": message, # Plain text fallback + } + + # Only add formatted_body if there's actual HTML formatting + if html_body != message: + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = html_body + + # Send message + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent message to {room_id}: {message[:50]}...") + return f"✓ Message sent to room (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send message: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send message: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_send_emote(room_id: str, action: str) -> str: + """ + Send an emote/action to a Matrix room (like /me in IRC). + + Displays as "* username action" instead of "username: message". + Supports markdown-style formatting (*italic*, **bold**). + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + action: Action text (supports *italic*, **bold**) + + Returns: + Confirmation message or error + + Examples: + matrix_send_emote("!abc123:matrix.org", "waves hello") + matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Convert markdown to HTML + html_body = _convert_markdown_to_html(action) + + # Build message content + content = { + "msgtype": "m.emote", + "body": action, # Plain text fallback + } + + # Only add formatted_body if there's actual HTML formatting + if html_body != action: + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = html_body + + # Send message + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent emote to {room_id}: {action[:50]}...") + return f"✓ Emote sent to room (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send emote: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send emote: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +def get_matrix_status() -> str: + """ + Get Matrix integration status. + + Shows: + - Connection status + - Number of queued messages + - Last Matrix wake time + - Rate limit status + + Returns: + Formatted status report + """ + try: + state = load_state() + + status_lines = ["Matrix Integration Status:"] + status_lines.append("=" * 40) + + # Credentials check + if CREDENTIALS_FILE.exists(): + try: + creds = load_credentials() + status_lines.append(f"✓ Connected as: {creds['user_id']}") + status_lines.append(f" Homeserver: {creds['homeserver']}") + + # Room whitelist + whitelist = creds.get('room_whitelist', []) + if whitelist: + status_lines.append(f" Monitoring: {len(whitelist)} rooms (whitelist)") + else: + status_lines.append(f" Monitoring: All rooms") + + except Exception as e: + status_lines.append(f"⚠️ Credentials error: {e}") + else: + status_lines.append("✗ Not configured (run setup_matrix.sh)") + + # Message queue + messages = state.get('matrix_messages', []) + unprocessed_msgs = [msg for msg in messages if not msg.get('processed', False)] + status_lines.append(f"Messages queued: {len(unprocessed_msgs)} unprocessed, {len(messages)} total") + + # Invite queue + invites = state.get('matrix_invites', []) + unprocessed_invs = [inv for inv in invites if not inv.get('processed', False)] + status_lines.append(f"Invites queued: {len(unprocessed_invs)} unprocessed, {len(invites)} total") + + # Last wake + last_wake_str = state.get('matrix_last_wake') + if last_wake_str: + try: + last_wake = datetime.fromisoformat(last_wake_str) + time_ago = datetime.now() - last_wake + status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") + except: + status_lines.append(f"Last Matrix wake: {last_wake_str}") + else: + status_lines.append("Last Matrix wake: Never") + + # Rate limit status + if last_wake_str: + try: + last_wake = datetime.fromisoformat(last_wake_str) + elapsed = (datetime.now() - last_wake).total_seconds() + if elapsed >= 120: + status_lines.append("Rate limit: OK (can wake)") + else: + remaining = 120 - elapsed + status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)") + except: + status_lines.append("Rate limit: Unknown") + else: + status_lines.append("Rate limit: OK (can wake)") + + # Wake request flag + if state.get('matrix_wake_requested'): + status_lines.append("⚡ Wake requested: Yes (daemon will wake Claude)") + else: + status_lines.append("Wake requested: No") + + return "\n".join(status_lines) + + except Exception as e: + logger.error(f"Failed to get status: {e}") + return f"Error getting status: {str(e)}" + + +@mcp.tool() +def list_matrix_invites(include_processed: bool = False) -> List[Dict[str, Any]]: + """ + List pending Matrix room invites. + + **Invites are automatically marked as processed when retrieved!** + + Shows all rooms the bot has been invited to but hasn't joined yet. + + Args: + include_processed: Include already-processed invites (default: False) + + Returns: + List of invites with structure: + - room_id: Matrix room ID + - room_name: Human-readable room name + - inviter: Who invited you + - timestamp: ISO timestamp + - processed: True (auto-marked when retrieved) + + Examples: + list_matrix_invites() - Get unprocessed invites (auto-marks as processed) + list_matrix_invites(include_processed=True) - Include processed invites + """ + try: + state = load_state() + invites = state.get('matrix_invites', []) + + # Filter by processed status + if not include_processed: + invites = [inv for inv in invites if not inv.get('processed', False)] + + # Automatically mark these invites as processed (they're being retrieved) + room_ids_to_mark = [inv['room_id'] for inv in invites] + if room_ids_to_mark: + marked_count = _mark_invites_processed_internal(room_ids_to_mark) + logger.info(f"Auto-marked {marked_count} invites as processed") + + # Update the returned data to reflect processed status + for inv in invites: + inv['processed'] = True + + logger.info(f"Returned {len(invites)} Matrix invites") + return invites + + except Exception as e: + logger.error(f"Failed to list invites: {e}") + return [] + + +def _mark_invites_processed_internal(room_ids: List[str]) -> int: + """ + Internal helper to mark invites as processed. + + Returns: + Number of invites marked + """ + state = load_state() + invites = state.get('matrix_invites', []) + + marked_count = 0 + for invite in invites: + if invite['room_id'] in room_ids: + invite['processed'] = True + marked_count += 1 + + state['matrix_invites'] = invites + save_state(state) + + return marked_count + + +@mcp.tool() +def matrix_mark_invites_processed(room_ids: List[str]) -> str: + """ + Mark Matrix invites as processed. + + After joining or declining invites, mark them as processed so they + won't appear in future list_matrix_invites() calls. + + Args: + room_ids: List of Matrix room IDs to mark as processed + + Returns: + Confirmation message + + Examples: + mark_invites_processed(["!abc123:matrix.org", "!xyz789:matrix.org"]) + """ + try: + marked_count = _mark_invites_processed_internal(room_ids) + logger.info(f"Marked {marked_count} invites as processed") + return f"✓ Marked {marked_count} invites as processed" + + except Exception as e: + logger.error(f"Failed to mark invites: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def join_matrix_room(room_id: str) -> str: + """ + Join a Matrix room (accept invite or join public room). + + Use this to accept pending invites or join public rooms. + Automatically marks the invite as processed. + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + + Returns: + Confirmation message or error + + Examples: + join_matrix_room("!abc123:matrix.org") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Join room + logger.info(f"Attempting to join room: {room_id}") + response = await client.join(room_id) + + await client.close() + + if isinstance(response, JoinResponse): + logger.info(f"Successfully joined room: {room_id}") + + # Mark invite as processed + _mark_invites_processed_internal([room_id]) + + return f"✓ Joined room: {room_id}" + elif isinstance(response, JoinError): + logger.error(f"Failed to join room: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to join room: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def leave_matrix_room(room_id: str) -> str: + """ + Leave a Matrix room. + + Use this to leave rooms you no longer want to monitor. + + Args: + room_id: Matrix room ID (e.g., "!abc123:matrix.org") + + Returns: + Confirmation message or error + + Examples: + leave_matrix_room("!abc123:matrix.org") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Leave room + logger.info(f"Attempting to leave room: {room_id}") + response = await client.room_leave(room_id) + + await client.close() + + if isinstance(response, RoomLeaveResponse): + logger.info(f"Successfully left room: {room_id}") + return f"✓ Left room: {room_id}" + elif isinstance(response, RoomLeaveError): + logger.error(f"Failed to leave room: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to leave room: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def list_matrix_rooms() -> str: + """ + List Matrix rooms the bot is in. + + Shows room IDs, names, and member counts for all joined rooms. + + Returns: + Formatted list of rooms + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Sync to get rooms + await client.sync(timeout=30000) + + # Get room whitelist + whitelist = set(creds.get('room_whitelist', [])) + + # Format room list + rooms_lines = ["Matrix Rooms:"] + rooms_lines.append("=" * 60) + + for room_id, room in client.rooms.items(): + # Check if room is whitelisted + monitored = "✓" if (not whitelist or room_id in whitelist) else "✗" + + rooms_lines.append(f"{monitored} {room.display_name or room.room_id}") + rooms_lines.append(f" ID: {room_id}") + rooms_lines.append(f" Members: {len(room.users)}") + rooms_lines.append("") + + await client.close() + + logger.info(f"Listed {len(client.rooms)} Matrix rooms") + return "\n".join(rooms_lines) + + except Exception as e: + logger.error(f"Failed to list rooms: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_reply_to_message(room_id: str, event_id: str, message: str) -> str: + """ + Reply to a specific Matrix message (threaded reply). + + Creates a threaded reply that shows context of the original message. + + Args: + room_id: Matrix room ID + event_id: Event ID of message to reply to + message: Your reply text + + Returns: + Confirmation message or error + + Examples: + reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Send threaded reply + content = { + "msgtype": "m.text", + "body": message, + "m.relates_to": { + "m.in_reply_to": { + "event_id": event_id + } + } + } + + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent reply to {event_id} in {room_id}") + return f"✓ Reply sent (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send reply: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send reply: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> str: + """ + Add an emoji reaction to a Matrix message. + + Args: + room_id: Matrix room ID + event_id: Event ID of message to react to + emoji: Emoji to react with (e.g., "👍", "❤️", "😂") + + Returns: + Confirmation message or error + + Examples: + react_to_message("!abc:matrix.org", "$event123", "👍") + react_to_message("!abc:matrix.org", "$event123", "❤️") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Send reaction + content = { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event_id, + "key": emoji + } + } + + response = await client.room_send( + room_id=room_id, + message_type="m.reaction", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent reaction {emoji} to {event_id}") + return f"✓ Reacted with {emoji}" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send reaction: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" + + except Exception as e: + logger.error(f"Failed to send reaction: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]: + """ + Get a Matrix user's profile information. + + Returns display name, avatar URL, and other profile data. + + Args: + user_id: Matrix user ID (e.g., "@user:matrix.org") + + Returns: + Dict with profile info: display_name, avatar_url + + Examples: + get_user_profile("@friend:matrix.org") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Get display name + display_name_response = await client.get_displayname(user_id) + display_name = None + if isinstance(display_name_response, ProfileGetDisplayNameResponse): + display_name = display_name_response.displayname + + # Get avatar + avatar_response = await client.get_avatar(user_id) + avatar_url = None + if isinstance(avatar_response, ProfileGetAvatarResponse): + avatar_url = avatar_response.avatar_url + + await client.close() + + profile = { + "user_id": user_id, + "display_name": display_name or user_id, + "avatar_url": avatar_url, + } + + logger.info(f"Retrieved profile for {user_id}") + return profile + + except Exception as e: + logger.error(f"Failed to get user profile: {e}") + return {"error": str(e)} + + +@mcp.tool() +async def matrix_set_presence(status: str, message: str = "") -> str: + """ + Set your Matrix presence status. + + Args: + status: "online", "offline", or "unavailable" + message: Optional status message + + Returns: + Confirmation message or error + + Examples: + set_presence("online", "Working on tasks") + set_presence("away", "Out for lunch") + set_presence("offline") + """ + try: + # Validate status + valid_statuses = ["online", "offline", "unavailable"] + if status not in valid_statuses: + return f"Error: status must be one of {valid_statuses}" + + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Set presence + await client.set_presence(status, message if message else None) + + await client.close() + + logger.info(f"Set presence to {status}") + return f"✓ Presence set to {status}" + (f": {message}" if message else "") + + except Exception as e: + logger.error(f"Failed to set presence: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: str = None) -> str: + """ + Send a file to a Matrix room. + + Accepts either file path (string) or file data (bytes). Auto-detects type. + + Args: + room_id: Matrix room ID + file: File path string OR file bytes/data + filename: Filename to display + mime_type: Optional MIME type (auto-detected if None) + + Returns: + Confirmation message or error + + Examples: + send_matrix_file("!abc:matrix.org", "/path/to/document.pdf", "report.pdf") + send_matrix_file("!abc:matrix.org", file_bytes, "data.json", "application/json") + """ + try: + from pathlib import Path + import mimetypes + + # Auto-detect: file path vs file data + if isinstance(file, str): + # File path + file_path = Path(file).expanduser() + if not file_path.exists(): + return f"Error: File not found: {file_path}" + + with open(file_path, 'rb') as f: + file_data = f.read() + + # Auto-detect MIME type from path + if not mime_type: + mime_type, _ = mimetypes.guess_type(str(file_path)) + if not mime_type: + mime_type = 'application/octet-stream' + else: + # File data (bytes or object with .data) + if isinstance(file, bytes): + file_data = file + elif hasattr(file, 'data'): + file_data = file.data + else: + return "Error: file must be path string or bytes/data object" + + if not mime_type: + mime_type = 'application/octet-stream' + + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Upload file + logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)") + upload_response = await client.upload( + data_provider=lambda *args: file_data, + content_type=mime_type, + filename=filename, + filesize=len(file_data) + ) + + if isinstance(upload_response, UploadError): + await client.close() + return f"Error uploading file: {upload_response.message}" + + # Send file message + content = { + "msgtype": "m.file", + "body": filename, + "url": upload_response.content_uri, + "info": { + "mimetype": mime_type, + "size": len(file_data), + } + } + + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) + + await client.close() + + if isinstance(response, RoomSendResponse): + logger.info(f"Sent file to {room_id}: {filename}") + return f"✓ File sent: {filename} (event ID: {response.event_id})" + else: + return f"Error: Failed to send file" + + except Exception as e: + logger.error(f"Failed to send file: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def download_matrix_attachment(event_id: str, save_path: str = None) -> Any: + """ + Download a file attachment from a Matrix message. + + If save_path provided, saves to disk and returns path. + If no save_path, returns raw file bytes. + + Args: + event_id: Event ID of file message + save_path: Optional path to save file (if None, returns bytes) + + Returns: + File path (if saved) or file bytes (if not saved) + + Examples: + download_matrix_attachment("$event123", "~/Downloads/file.pdf") + download_matrix_attachment("$event123") # Returns bytes + """ + try: + from pathlib import Path + + # Find file in state + state = load_state() + messages = state.get('matrix_messages', []) + + file_msg = None + for msg in messages: + if msg.get('event_id') == event_id and msg.get('type') in ['image', 'file']: + file_msg = msg + break + + if not file_msg: + return "Error: File message not found with that event ID" + + # For images, decode from base64 + if file_msg['type'] == 'image': + import base64 + file_data = base64.b64decode(file_msg['image_data']) + else: + # For files, need to download from Matrix (would need mxc URL in state) + return "Error: File download not yet implemented for non-image files" + + # Save or return + if save_path: + save_path = Path(save_path).expanduser() + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'wb') as f: + f.write(file_data) + + logger.info(f"Saved file to {save_path}") + return f"✓ Saved to {save_path}" + else: + logger.info(f"Returning file bytes ({len(file_data)} bytes)") + return file_data + + except Exception as e: + logger.error(f"Failed to download attachment: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]: + """ + Get list of members in a Matrix room. + + Returns member list with display names and power levels. + + Args: + room_id: Matrix room ID + + Returns: + List of members with user_id, display_name, power_level + + Examples: + get_room_members("!abc:matrix.org") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Sync to get rooms + await client.sync(timeout=30000) + + # Find the room + if room_id not in client.rooms: + await client.close() + return [{"error": f"Room not found: {room_id}"}] + + room = client.rooms[room_id] + + # Get members + members = [] + for user_id, member in room.users.items(): + members.append({ + "user_id": user_id, + "display_name": member.display_name or user_id, + "power_level": member.power_level, + }) + + await client.close() + + logger.info(f"Retrieved {len(members)} members from {room_id}") + return members + + except Exception as e: + logger.error(f"Failed to get room members: {e}") + return [{"error": str(e)}] + + +@mcp.tool() +async def matrix_set_room_topic(room_id: str, topic: str) -> str: + """ + Set the topic/description of a Matrix room. + + Requires sufficient power level in the room. + + Args: + room_id: Matrix room ID + topic: New room topic/description + + Returns: + Confirmation message or error + + Examples: + set_room_topic("!abc:matrix.org", "Discussion about AI projects") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Set room topic + await client.room_put_state( + room_id=room_id, + event_type="m.room.topic", + content={"topic": topic} + ) + + await client.close() + + logger.info(f"Set topic for {room_id}: {topic[:50]}...") + return f"✓ Room topic updated" + + except Exception as e: + logger.error(f"Failed to set room topic: {e}") + return f"Error: {str(e)}" + + +@mcp.tool() +async def matrix_get_room_state(room_id: str) -> Dict[str, Any]: + """ + Get comprehensive state information about a Matrix room. + + Returns room name, topic, member count, encryption status, etc. + + Args: + room_id: Matrix room ID + + Returns: + Dict with room state information + + Examples: + get_room_state("!abc:matrix.org") + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Sync to get rooms + await client.sync(timeout=30000) + + # Find the room + if room_id not in client.rooms: + await client.close() + return {"error": f"Room not found: {room_id}"} + + room = client.rooms[room_id] + + # Build state dict + state = { + "room_id": room_id, + "name": room.name or room.display_name or room_id, + "topic": room.topic, + "member_count": len(room.users), + "encrypted": room.encrypted, + "room_version": getattr(room, 'room_version', 'unknown'), + } + + await client.close() + + logger.info(f"Retrieved state for {room_id}") + return state + + except Exception as e: + logger.error(f"Failed to get room state: {e}") + return {"error": str(e)} + + +@mcp.tool() +async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> List[Dict[str, Any]]: + """ + Search for messages in a Matrix room. + + Searches recent chat history for text matching the query. + + Args: + room_id: Matrix room ID + query: Search text + limit: Maximum number of results (default: 10) + + Returns: + List of matching messages with sender, text, timestamp + + Examples: + search_messages("!abc:matrix.org", "meeting", limit=5) + """ + try: + # Load credentials + creds = load_credentials() + + # Create Matrix client + MATRIX_DATA_DIR.mkdir(exist_ok=True) + client = AsyncClient( + homeserver=creds['homeserver'], + user=creds['user_id'], + store_path=str(MATRIX_DATA_DIR), + ) + + # Restore session + client.access_token = creds['access_token'] + client.device_id = creds['device_id'] + + # Get room messages + response = await client.room_messages( + room_id=room_id, + start="", + limit=100 # Search last 100 messages + ) + + results = [] + + if isinstance(response, RoomMessagesResponse): + # Search through messages + for event in response.chunk: + if hasattr(event, 'body') and query.lower() in event.body.lower(): + results.append({ + "sender": event.sender, + "message": event.body, + "timestamp": datetime.fromtimestamp(event.server_timestamp / 1000).isoformat(), + "event_id": event.event_id, + }) + + if len(results) >= limit: + break + + await client.close() + + logger.info(f"Found {len(results)} messages matching '{query}'") + return results + + except Exception as e: + logger.error(f"Failed to search messages: {e}") + return [{"error": str(e)}] + + +if __name__ == "__main__": + logger.info("=" * 60) + logger.info("Matrix Control MCP Server starting...") + logger.info(f"State file: {STATE_FILE}") + logger.info(f"Credentials file: {CREDENTIALS_FILE}") + logger.info("=" * 60) + + # Run MCP server (uses stdio transport by default) + mcp.run() diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..2961bb6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Claude Desktop Automation Dependencies + +# Core dependencies +fastmcp>=0.2.0 + +# Matrix integration (optional) +# Install with: pip3 install -r requirements.txt +# Or selectively: pip3 install fastmcp (core only) +# Note: Without [e2e], only unencrypted Matrix rooms are supported +matrix-nio>=0.20.0 +Pillow>=10.0.0 diff --git a/send_to_claude.py b/send_to_claude.py new file mode 100755 index 0000000..ec903d7 --- /dev/null +++ b/send_to_claude.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Send message to Claude Desktop using AppleScript + +Features: +- CMD+R refresh before sending (with configurable delay) +- Reliable AppleScript-based automation +- Better error handling and state management + +Usage: + python3 send_to_claude.py "Your message here" + python3 send_to_claude.py "Message" --no-refresh + python3 send_to_claude.py # Uses default message +""" + +import sys +import time +import logging +import subprocess +from datetime import datetime +from pathlib import Path + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Configuration +REFRESH_DELAY_SECONDS = 15 # Wait time after CMD+R to ensure refresh completes + + +def wake_screen(): + """Wake screen if screen saver is active""" + try: + subprocess.run(['caffeinate', '-u', '-t', '1'], check=True) + time.sleep(0.5) + logger.debug("Screen wake command sent") + except Exception as e: + logger.debug(f"Screen wake failed (non-critical): {e}") + + +def activate_claude(): + """ + Activate Claude Desktop, launching if needed + + Returns: + bool: True if successful + """ + try: + # Try to activate if already running + result = subprocess.run([ + 'osascript', '-e', + 'tell application "Claude" to activate' + ], capture_output=True, text=True, timeout=5) + + if result.returncode == 0: + logger.info("Activated Claude Desktop") + time.sleep(0.5) + return True + + except Exception as e: + logger.debug(f"Activation attempt failed: {e}") + + # Launch if not running + try: + logger.info("Claude not running, launching...") + subprocess.run(['open', '-a', 'Claude'], check=True, timeout=5) + time.sleep(2) + logger.info("Launched Claude Desktop") + return True + + except Exception as e: + logger.error(f"Failed to launch Claude: {e}") + return False + + +def send_refresh(delay_seconds=REFRESH_DELAY_SECONDS): + """ + Send CMD+R to refresh and wait for completion + + Args: + delay_seconds: Time to wait after CMD+R (default: 10) + + Returns: + bool: True if successful + """ + try: + logger.info("Sending CMD+R to refresh...") + + subprocess.run([ + 'osascript', '-e', + 'tell application "System Events" to keystroke "r" using command down' + ], check=True, timeout=2) + + logger.info(f"Waiting {delay_seconds}s for refresh to complete...") + time.sleep(delay_seconds) + logger.info("✓ Refresh complete") + return True + + except Exception as e: + logger.error(f"Failed to send CMD+R: {e}") + return False + + +def send_message_applescript(message): + """ + Send message to Claude Desktop using AppleScript + + Args: + message: Message text to send + + Returns: + bool: True if successful + """ + try: + logger.info(f"Sending message: {message[:50]}...") + + # Escape backslashes and quotes for AppleScript + escaped_message = message.replace('\\', '\\\\').replace('"', '\\"') + + subprocess.run([ + 'osascript', '-e', + f'tell application "System Events" to keystroke "{escaped_message}"' + ], check=True, timeout=10) + + # Wait for Claude app input box to become ready + # (app blocks input briefly after receiving text) + logger.info("Waiting 15s for input box to become ready...") + time.sleep(15) + + subprocess.run([ + 'osascript', '-e', + 'tell application "System Events" to keystroke return' + ], check=True, timeout=2) + + logger.info("✓ Message sent successfully") + return True + + except Exception as e: + logger.error(f"Failed to send message: {e}") + return False + + +def send_to_claude(message, do_refresh=True): + """ + Send message to Claude Desktop + + Args: + message: Message text to send + do_refresh: Whether to send CMD+R before sending (default: True) + + Returns: + bool: True if successful + """ + try: + # Wake screen + wake_screen() + + # Activate Claude + if not activate_claude(): + logger.error("Could not activate Claude Desktop") + return False + + # Send refresh if requested + if do_refresh: + if not send_refresh(): + logger.warning("Refresh failed, continuing anyway...") + + # Send message + return send_message_applescript(message) + + except Exception as e: + logger.error(f"ERROR: {e}") + import traceback + logger.debug(traceback.format_exc()) + return False + + +def main(): + """Main entry point""" + # Parse arguments + if len(sys.argv) < 2: + message = f"System check at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + else: + message = sys.argv[1] + + # Check for --no-refresh flag + do_refresh = '--no-refresh' not in sys.argv + + logger.info("=" * 60) + logger.info("Claude Desktop Automation - Send Message") + logger.info(f"Refresh: {'Yes' if do_refresh else 'No'}") + logger.info(f"Refresh delay: {REFRESH_DELAY_SECONDS}s") + logger.info("=" * 60) + + success = send_to_claude(message, do_refresh=do_refresh) + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main() diff --git a/send_to_claude.scpt b/send_to_claude.scpt new file mode 100755 index 0000000..dc26034 --- /dev/null +++ b/send_to_claude.scpt @@ -0,0 +1,68 @@ +-- Send Message to Claude Desktop +-- This AppleScript sends a message to the currently active chat in Claude Desktop +-- +-- Usage: osascript send_to_claude.scpt "Your message here" +-- +-- Requirements: +-- - Claude Desktop must be running +-- - Accessibility permissions must be granted +-- - A chat must be active (any chat - cannot target specific chat) + +on run argv + -- Get message from command line argument + if (count of argv) = 0 then + set messageText to "System check at " & (current date) as string + else + set messageText to item 1 of argv + end if + + -- Log the message + log "Attempting to send: " & messageText + + try + -- Wake the screen if screen saver is active + -- This simulates a brief mouse movement to dismiss screen saver + log "Waking screen if needed..." + do shell script "caffeinate -u -t 1" + delay 0.5 + + -- Check if Claude is running, launch if not + if not application "Claude" is running then + log "Claude not running, launching..." + tell application "Claude" to activate + delay 2 -- Wait for app to launch + else + -- Bring Claude to front + tell application "Claude" to activate + delay 0.5 -- Brief wait for window activation + end if + + -- Send the message using System Events + tell application "System Events" + tell process "Claude" + -- Verify the window exists + if not (exists window 1) then + error "Claude window not found" + end if + + -- Type the message + keystroke messageText + + -- Wait for Claude app input box to become ready + -- (app blocks input briefly after receiving text) + delay 15 + + -- Press Enter to send + keystroke return + + log "Message sent successfully" + end tell + end tell + + return "SUCCESS: Message sent to Claude Desktop" + + on error errMsg number errNum + log "ERROR: " & errMsg & " (" & errNum & ")" + return "ERROR: " & errMsg + end try +end run diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..26158a8 --- /dev/null +++ b/setup.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# +# Setup Script for Claude Desktop Automation +# +# This script: +# 1. Checks for required files and permissions +# 2. Installs the automation daemon (launchd agent) +# 3. Configures the Wakeup Control MCP server +# 4. Tests the automation +# 5. Provides troubleshooting guidance + +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PLIST_NAME="com.claude.monitor.plist" +PLIST_SOURCE="${SCRIPT_DIR}/${PLIST_NAME}" +PLIST_DEST="${HOME}/Library/LaunchAgents/${PLIST_NAME}" +CLAUDE_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + +echo "=========================================" +echo "Claude Desktop Automation Setup" +echo "=========================================" +echo "" +echo "This will set up:" +echo "1. Automation Daemon (sends periodic messages)" +echo "2. Wakeup Control MCP (lets Claude adjust timing)" +echo "" + +# Step 1: Check if files exist +echo "[1/6] Checking files..." +if [[ ! -f "${SCRIPT_DIR}/send_to_claude.scpt" ]]; then + echo "ERROR: send_to_claude.scpt not found" + exit 1 +fi +if [[ ! -f "${SCRIPT_DIR}/automation_daemon.py" ]]; then + echo "ERROR: automation_daemon.py not found" + exit 1 +fi +if [[ ! -f "${SCRIPT_DIR}/wakeup_mcp.py" ]]; then + echo "ERROR: wakeup_mcp.py not found" + exit 1 +fi +if [[ ! -f "${PLIST_SOURCE}" ]]; then + echo "ERROR: ${PLIST_NAME} not found" + exit 1 +fi +echo "✓ All files present" +echo "" + +# Step 2: Check Python dependencies +echo "[2/6] Checking Python dependencies..." +if ! python3 -c "import fastmcp" 2>/dev/null; then + echo "⚠️ FastMCP not installed" + read -p "Install fastmcp? (y/n) " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + pip3 install fastmcp + else + echo "ERROR: FastMCP is required for the MCP server" + exit 1 + fi +fi +echo "✓ Dependencies OK" +echo "" + +# Step 3: Check permissions +echo "[3/6] Checking Accessibility permissions..." +echo "NOTE: Accessibility permissions are required for UI automation" +echo "" +echo "To grant permissions:" +echo "1. Open System Settings/Preferences" +echo "2. Go to Privacy & Security → Accessibility" +echo "3. Add Terminal (or your terminal app)" +echo "4. Enable the checkbox" +echo "" +read -p "Have you granted Accessibility permissions? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Please grant permissions and run this script again" + exit 1 +fi +echo "" + +# Step 4: Configure MCP server +echo "[4/6] Configuring Wakeup Control MCP..." +echo "Adding MCP server to Claude Desktop config" +echo "" + +# Create config backup +if [[ -f "${CLAUDE_CONFIG}" ]]; then + cp "${CLAUDE_CONFIG}" "${CLAUDE_CONFIG}.backup" + echo "✓ Created backup: ${CLAUDE_CONFIG}.backup" +fi + +# Add MCP server config (manual for now) +echo "Add this to your Claude Desktop config (${CLAUDE_CONFIG}):" +echo "" +echo '{' +echo ' "mcpServers": {' +echo ' "wakeup-control": {' +echo ' "command": "python3",' +echo ' "args": ["'${SCRIPT_DIR}'/wakeup_mcp.py"]' +echo ' }' +echo ' }' +echo '}' +echo "" +read -p "Have you added the MCP server to your config? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "You can add it later - continuing with daemon setup..." +fi +echo "" + +# Step 5: Install daemon +echo "[5/6] Installing automation daemon..." + +# Create LaunchAgents directory if it doesn't exist +mkdir -p "${HOME}/Library/LaunchAgents" + +# Update paths in plist to match user's home directory +sed "s|/Users/alex|${HOME}|g" "${PLIST_SOURCE}" > "${PLIST_DEST}" + +echo "✓ Installed to ${PLIST_DEST}" +echo "" + +# Load the agent +echo "Loading daemon..." +if launchctl list | grep -q "com.claude.monitor"; then + echo "Unloading existing daemon..." + launchctl unload "${PLIST_DEST}" 2>/dev/null || true + sleep 1 +fi + +launchctl load "${PLIST_DEST}" +echo "✓ Daemon loaded and running" +echo "" + +# Step 6: Test (optional) +echo "[6/6] Testing (optional)..." +echo "You can test the automation manually, or let it run on schedule" +echo "" +read -p "Test now? This will send a message to Claude Desktop (y/n) " -n 1 -r +echo "" +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Make sure Claude Desktop is running with a chat open!" + sleep 2 + if python3 "${SCRIPT_DIR}/automation_daemon.py" & then + DAEMON_PID=$! + echo "Daemon started (PID: $DAEMON_PID)" + echo "Watch the logs: tail -f /tmp/claude-automation-daemon.log" + echo "" + read -p "Press Enter to stop the test daemon..." + kill $DAEMON_PID 2>/dev/null || true + fi +else + echo "Skipping test..." +fi +echo "" + +echo "=========================================" +echo "Setup Complete!" +echo "=========================================" +echo "" +echo "The automation is now running:" +echo "" +echo "📡 Daemon: Sends messages every 60 minutes (default)" +echo "🎛️ MCP Server: Lets Claude control wake timing" +echo "" +echo "Useful commands:" +echo "- View daemon logs: tail -f /tmp/claude-automation-daemon.log" +echo "- View MCP logs: tail -f /tmp/wakeup-mcp.log" +echo "- Stop daemon: launchctl unload ${PLIST_DEST}" +echo "- Start daemon: launchctl load ${PLIST_DEST}" +echo "- Restart daemon: launchctl kickstart -k gui/\$(id -u)/com.claude.monitor" +echo "" +echo "In Claude Desktop, use these tools:" +echo "- next_wakeup(minutes) - Adjust next wake time" +echo "- pause_automation() - Pause wake messages" +echo "- resume_automation() - Resume wake messages" +echo "- get_status() - Check automation status" +echo "" +echo "After your first wake message, tell Claude:" +echo '"Use next_wakeup(30) to wake me in 30 minutes instead"' +echo "" diff --git a/setup_matrix.sh b/setup_matrix.sh new file mode 100755 index 0000000..cd4fee8 --- /dev/null +++ b/setup_matrix.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# +# Matrix Integration Setup Script +# +# This script: +# 1. Installs Python dependencies (matrix-nio) +# 2. Authenticates with Matrix homeserver +# 3. Saves credentials securely +# 4. Configures room whitelist (optional) +# 5. Adds Matrix MCP to Claude Desktop config +# 6. Tests the integration + +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +CREDENTIALS_FILE="${HOME}/.matrix-credentials.json" +STATE_FILE="${HOME}/.claude-automation-state.json" +CLAUDE_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + +echo "=========================================" +echo "Matrix Integration Setup" +echo "=========================================" +echo "" + +# Step 1: Install dependencies +echo "[1/6] Installing Python dependencies..." +if ! python3 -c "import nio" 2>/dev/null; then + echo "Installing matrix-nio (without E2EE - unencrypted rooms only)..." + pip3 install matrix-nio Pillow +else + echo "✓ matrix-nio already installed" +fi +echo "" + +# Step 2: Matrix authentication +echo "[2/6] Matrix Authentication" +echo "You'll need:" +echo " - Homeserver URL (e.g., https://matrix.org)" +echo " - Matrix username (e.g., @user:matrix.org)" +echo " - Password" +echo "" + +read -p "Homeserver URL: " HOMESERVER +read -p "Matrix User ID: " USER_ID +read -s -p "Password: " PASSWORD +echo "" +echo "" + +echo "Authenticating with Matrix..." + +# Create temporary Python script to authenticate +AUTH_SCRIPT=$(mktemp /tmp/matrix-auth.XXXXXX.py) +cat > "$AUTH_SCRIPT" << 'EOPYTHON' +#!/usr/bin/env python3 +import asyncio +import json +import sys +from nio import AsyncClient, LoginResponse + +async def login(homeserver, user_id, password): + client = AsyncClient(homeserver, user_id) + + try: + response = await client.login(password) + + if isinstance(response, LoginResponse): + print(json.dumps({ + 'success': True, + 'homeserver': homeserver, + 'user_id': response.user_id, + 'access_token': response.access_token, + 'device_id': response.device_id, + })) + else: + print(json.dumps({ + 'success': False, + 'error': str(response) + })) + except Exception as e: + print(json.dumps({ + 'success': False, + 'error': str(e) + })) + finally: + await client.close() + +if __name__ == "__main__": + homeserver = sys.argv[1] + user_id = sys.argv[2] + password = sys.argv[3] + + asyncio.run(login(homeserver, user_id, password)) +EOPYTHON + +# Run authentication +AUTH_RESULT=$(python3 "$AUTH_SCRIPT" "$HOMESERVER" "$USER_ID" "$PASSWORD") +rm "$AUTH_SCRIPT" + +# Parse result +SUCCESS=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))") + +if [ "$SUCCESS" != "True" ]; then + ERROR=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('error', 'Unknown error'))") + echo "✗ Authentication failed: $ERROR" + exit 1 +fi + +echo "✓ Authentication successful" +echo "" + +# Step 3: Save credentials +echo "[3/6] Saving credentials..." + +# Extract credentials +HOMESERVER=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['homeserver'])") +USER_ID=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['user_id'])") +ACCESS_TOKEN=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])") +DEVICE_ID=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['device_id'])") + +# Create credentials file +cat > "$CREDENTIALS_FILE" << EOF +{ + "homeserver": "$HOMESERVER", + "user_id": "$USER_ID", + "access_token": "$ACCESS_TOKEN", + "device_id": "$DEVICE_ID", + "room_whitelist": [] +} +EOF + +# Secure permissions +chmod 600 "$CREDENTIALS_FILE" + +echo "✓ Credentials saved to $CREDENTIALS_FILE (chmod 600)" +echo "" + +# Step 4: Room whitelist (optional) +echo "[4/6] Room Configuration" +echo "" +echo "Do you want to monitor ALL rooms or only specific rooms?" +echo " 1. All rooms (default)" +echo " 2. Specific rooms only (whitelist)" +read -p "Choice [1/2]: " ROOM_CHOICE +echo "" + +if [ "$ROOM_CHOICE" = "2" ]; then + echo "Fetching your Matrix rooms..." + + # Create temporary Python script to list rooms + LIST_ROOMS_SCRIPT=$(mktemp /tmp/matrix-rooms.XXXXXX.py) + cat > "$LIST_ROOMS_SCRIPT" << 'EOPYTHON' +#!/usr/bin/env python3 +import asyncio +import json +import sys +from nio import AsyncClient +from pathlib import Path + +async def list_rooms(homeserver, user_id, access_token, device_id): + store_path = Path.home() / ".matrix-data" + store_path.mkdir(exist_ok=True) + + client = AsyncClient(homeserver, user_id, store_path=str(store_path)) + client.access_token = access_token + client.device_id = device_id + + try: + await client.sync(timeout=30000) + + rooms = [] + for room_id, room in client.rooms.items(): + rooms.append({ + 'room_id': room_id, + 'name': room.display_name or room_id, + 'members': len(room.users) + }) + + print(json.dumps(rooms, indent=2)) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + await client.close() + +if __name__ == "__main__": + asyncio.run(list_rooms( + sys.argv[1], + sys.argv[2], + sys.argv[3], + sys.argv[4] + )) +EOPYTHON + + ROOMS=$(python3 "$LIST_ROOMS_SCRIPT" "$HOMESERVER" "$USER_ID" "$ACCESS_TOKEN" "$DEVICE_ID") + rm "$LIST_ROOMS_SCRIPT" + + echo "Your Matrix rooms:" + echo "$ROOMS" | python3 -c " +import sys, json +rooms = json.load(sys.stdin) +for i, room in enumerate(rooms): + print(f\"{i+1}. {room['name']} ({room['members']} members)\") + print(f\" ID: {room['room_id']}\") +" + echo "" + echo "Enter room numbers to monitor (space-separated, e.g., '1 3 5'):" + read -p "Room numbers: " ROOM_NUMBERS + + # Parse selected rooms + SELECTED_ROOMS=$(echo "$ROOMS" | python3 -c " +import sys, json +rooms = json.load(sys.stdin) +selected = '$ROOM_NUMBERS'.split() +whitelist = [rooms[int(num)-1]['room_id'] for num in selected if num.isdigit() and 0 < int(num) <= len(rooms)] +print(json.dumps(whitelist)) +") + + # Update credentials file + python3 -c " +import json +with open('$CREDENTIALS_FILE', 'r') as f: + creds = json.load(f) +creds['room_whitelist'] = $SELECTED_ROOMS +with open('$CREDENTIALS_FILE', 'w') as f: + json.dump(creds, f, indent=2) +" + + echo "✓ Room whitelist configured" +else + echo "✓ Monitoring all rooms" +fi +echo "" + +# Step 5: Configure Claude Desktop MCP +echo "[5/6] Configuring Matrix MCP in Claude Desktop..." +echo "" + +# Check if config exists +if [ ! -f "$CLAUDE_CONFIG" ]; then + echo "Creating Claude Desktop config..." + mkdir -p "$(dirname "$CLAUDE_CONFIG")" + echo '{"mcpServers": {}}' > "$CLAUDE_CONFIG" +fi + +# Backup config +cp "$CLAUDE_CONFIG" "${CLAUDE_CONFIG}.backup" +echo "✓ Created backup: ${CLAUDE_CONFIG}.backup" + +echo "" +echo "Add this to your Claude Desktop config (${CLAUDE_CONFIG}):" +echo "" +echo ' "matrix-control": {' +echo ' "command": "python3",' +echo " \"args\": [\"${SCRIPT_DIR}/matrix_mcp.py\"]" +echo ' }' +echo "" +read -p "Have you added the Matrix MCP to your config? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "You can add it later - continuing with setup..." +fi +echo "" + +# Step 6: Start Matrix monitor +echo "[6/6] Starting Matrix Monitor" +echo "" +echo "The Matrix monitor will run in the background and queue messages." +echo "The automation daemon will integrate with it to wake Claude." +echo "" +read -p "Start Matrix monitor now? (y/n) " -n 1 -r +echo "" + +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Starting Matrix monitor in background..." + nohup python3 "${SCRIPT_DIR}/matrix_integration.py" > /tmp/matrix-monitor.out 2>&1 & + MONITOR_PID=$! + echo "✓ Matrix monitor started (PID: $MONITOR_PID)" + echo "" + echo "To stop: kill $MONITOR_PID" + echo "Logs: tail -f /tmp/matrix-integration.log" +else + echo "Skipped - you can start it manually later with:" + echo " python3 ${SCRIPT_DIR}/matrix_integration.py &" +fi +echo "" + +echo "=========================================" +echo "Matrix Integration Setup Complete!" +echo "=========================================" +echo "" +echo "Configuration:" +echo " User: $USER_ID" +echo " Homeserver: $HOMESERVER" +echo " Credentials: $CREDENTIALS_FILE (chmod 600)" +echo "" +echo "Next steps:" +echo "" +echo "1. Restart Claude Desktop to load the Matrix MCP" +echo "" +echo "2. The automation daemon will now:" +echo " - Queue Matrix messages to state file" +echo " - Wake Claude when new messages arrive (2-min rate limit)" +echo " - Allow Claude to use Matrix MCP tools" +echo "" +echo "3. In Claude Desktop, use these tools:" +echo " - get_matrix_messages() - Retrieve queued messages" +echo " - send_matrix_message(room_id, message) - Respond to messages" +echo " - mark_messages_processed(event_ids) - Mark as processed" +echo " - get_matrix_status() - Check Matrix status" +echo " - list_matrix_rooms() - List available rooms" +echo "" +echo "Useful commands:" +echo " - View Matrix logs: tail -f /tmp/matrix-integration.log" +echo " - View MCP logs: tail -f /tmp/matrix-mcp.log" +echo " - View daemon logs: tail -f /tmp/claude-automation-daemon.log" +echo " - Check Matrix monitor: ps aux | grep matrix_integration.py" +echo "" diff --git a/wakeup_mcp.py b/wakeup_mcp.py new file mode 100755 index 0000000..a870eaf --- /dev/null +++ b/wakeup_mcp.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Claude Desktop Automation - Wakeup Control MCP Server + +Model Context Protocol server that allows Claude to control the automation daemon's +wake schedule. + +Tools: +- next_wakeup(minutes) - Set when to wake next (overrides default 60min) +- pause_automation() - Pause the automation +- resume_automation() - Resume the automation +- get_status() - Check automation status and next wake time +""" + +import json +import logging +import fcntl +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +from fastmcp import FastMCP + +# Configuration +STATE_FILE = Path.home() / ".claude-automation-state.json" +LOG_FILE = Path("/tmp/wakeup-mcp.log") + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Initialize MCP server +mcp = FastMCP("Wakeup Control") + + +def load_state() -> dict: + """Load automation state from JSON file""" + if not STATE_FILE.exists(): + return { + 'interval_minutes': 60, + 'paused': False, + 'last_wake': None, + 'next_wake_timestamp': None, + 'matrix_messages': [], + 'matrix_invites': [], + 'matrix_last_wake': None, + 'matrix_wake_requested': False, + } + + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load state: {e}") + return {} + + +def save_state(state: dict): + """Save automation state to JSON file atomically with file locking""" + try: + temp_path = STATE_FILE.with_suffix('.tmp') + with open(temp_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + temp_path.rename(STATE_FILE) + logger.info(f"Saved state atomically") + except Exception as e: + logger.error(f"Failed to save state: {e}") + + +@mcp.tool() +def next_wakeup(minutes: int) -> str: + """ + Set when the automation should wake up next. + + This overrides the default 60-minute interval for the NEXT wake cycle only. + After that wake, it reverts to the default interval unless you call this again. + + Use this to adjust monitoring frequency based on current activity: + - High activity: next_wakeup(15) for more frequent checks + - Low activity: next_wakeup(120) for less frequent checks + - Skip next: next_wakeup(1440) to wake tomorrow + + Args: + minutes: How many minutes from now to wake up next (min: 1, max: 1440) + + Returns: + Confirmation message with next wake time + + Examples: + next_wakeup(30) - Wake in 30 minutes + next_wakeup(120) - Wake in 2 hours + next_wakeup(5) - Wake in 5 minutes (urgent check) + """ + # Validate input + if not isinstance(minutes, int): + return f"Error: minutes must be an integer, got {type(minutes)}" + + if minutes < 1: + return "Error: minutes must be at least 1" + + if minutes > 1440: + return "Error: minutes cannot exceed 1440 (24 hours)" + + try: + # Calculate next wake time + next_wake = datetime.now() + timedelta(minutes=minutes) + + # Load current state + state = load_state() + + # Update next wake timestamp + state['next_wake_timestamp'] = next_wake.isoformat() + + # Save state + save_state(state) + + logger.info(f"Next wake set to {next_wake.strftime('%Y-%m-%d %H:%M:%S')} ({minutes} minutes)") + + return f"✓ Next wake scheduled for {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {minutes} minutes)" + + except Exception as e: + logger.error(f"Failed to set next wakeup: {e}") + return f"Error setting next wakeup: {str(e)}" + + +@mcp.tool() +def pause_automation() -> str: + """ + Pause the automation daemon. + + The daemon will stop sending wake messages until you call resume_automation(). + The daemon process continues running but skips wake cycles. + + Use this when: + - You don't need monitoring for a while + - Working on something that shouldn't be interrupted + - Testing or debugging + + Call resume_automation() to restart wake messages. + + Returns: + Confirmation message + """ + try: + state = load_state() + state['paused'] = True + save_state(state) + + logger.info("Automation paused") + return "✓ Automation paused - no more wake messages until you call resume_automation()" + + except Exception as e: + logger.error(f"Failed to pause automation: {e}") + return f"Error pausing automation: {str(e)}" + + +@mcp.tool() +def resume_automation() -> str: + """ + Resume the automation daemon. + + Restarts wake messages after being paused. The next wake will happen + according to the configured interval (default 60 minutes). + + Returns: + Confirmation message + """ + try: + state = load_state() + state['paused'] = False + save_state(state) + + logger.info("Automation resumed") + return "✓ Automation resumed - wake messages will continue on schedule" + + except Exception as e: + logger.error(f"Failed to resume automation: {e}") + return f"Error resuming automation: {str(e)}" + + +@mcp.tool() +def get_status() -> str: + """ + Get current automation status. + + Shows: + - Whether automation is running or paused + - When the last wake happened + - When the next wake is scheduled + - Current interval setting + + Returns: + Formatted status report + """ + try: + state = load_state() + + status_lines = ["Automation Status:"] + status_lines.append("=" * 40) + + # Paused status + if state.get('paused'): + status_lines.append("⏸️ State: PAUSED") + else: + status_lines.append("▶️ State: RUNNING") + + # Last wake + if state.get('last_wake'): + try: + last_wake = datetime.fromisoformat(state['last_wake']) + time_ago = datetime.now() - last_wake + status_lines.append(f"Last wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") + except: + status_lines.append(f"Last wake: {state.get('last_wake')}") + else: + status_lines.append("Last wake: Never (daemon just started)") + + # Next wake + if state.get('next_wake_timestamp'): + try: + next_wake = datetime.fromisoformat(state['next_wake_timestamp']) + time_until = next_wake - datetime.now() + if time_until.total_seconds() > 0: + status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)") + else: + status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})") + except: + status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}") + else: + interval = state.get('interval_minutes', 60) + status_lines.append(f"Next wake: Using default interval ({interval} minutes)") + + # Interval + interval = state.get('interval_minutes', 60) + status_lines.append(f"Default interval: {interval} minutes") + + return "\n".join(status_lines) + + except Exception as e: + logger.error(f"Failed to get status: {e}") + return f"Error getting status: {str(e)}" + + +if __name__ == "__main__": + logger.info("=" * 60) + logger.info("Wakeup Control MCP Server starting...") + logger.info(f"State file: {STATE_FILE}") + logger.info("=" * 60) + + # Run MCP server (uses stdio transport by default) + mcp.run()