Initial commit: claude-automation 🦊
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 💕
This commit is contained in:
725
README.md
Executable file
725
README.md
Executable file
@@ -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** 🤖
|
||||
433
automation_daemon.py
Executable file
433
automation_daemon.py
Executable file
@@ -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)
|
||||
400
automation_daemon_v2.py
Normal file
400
automation_daemon_v2.py
Normal file
@@ -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)
|
||||
51
com.claude.monitor.plist
Executable file
51
com.claude.monitor.plist
Executable file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Label: Unique identifier for this job -->
|
||||
<key>Label</key>
|
||||
<string>com.claude.monitor</string>
|
||||
|
||||
<!-- Program to run: The automation daemon (handles its own scheduling) -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>/Users/alex/scripts/claude-desktop-automation/automation_daemon.py</string>
|
||||
</array>
|
||||
|
||||
<!-- Run on login (start daemon when user logs in) -->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<!-- Keep the daemon running (restart if it crashes) -->
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<!-- Standard output and error logs -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/claude-monitor.out</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/claude-monitor.err</string>
|
||||
|
||||
<!-- Working directory -->
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/alex/scripts/claude-desktop-automation</string>
|
||||
|
||||
<!-- Only run when user is logged in (GUI session required for AppleScript) -->
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
|
||||
<!-- Throttle restart attempts (wait 10 seconds after crash before restart) -->
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<!-- Environment variables (if needed) -->
|
||||
<!--
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
780
matrix_integration.py
Executable file
780
matrix_integration.py
Executable file
@@ -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())
|
||||
1853
matrix_mcp.py
Executable file
1853
matrix_mcp.py
Executable file
File diff suppressed because it is too large
Load Diff
11
requirements.txt
Executable file
11
requirements.txt
Executable file
@@ -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
|
||||
203
send_to_claude.py
Executable file
203
send_to_claude.py
Executable file
@@ -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()
|
||||
68
send_to_claude.scpt
Executable file
68
send_to_claude.scpt
Executable file
@@ -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
|
||||
184
setup.sh
Executable file
184
setup.sh
Executable file
@@ -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 ""
|
||||
317
setup_matrix.sh
Executable file
317
setup_matrix.sh
Executable file
@@ -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 ""
|
||||
262
wakeup_mcp.py
Executable file
262
wakeup_mcp.py
Executable file
@@ -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()
|
||||
Reference in New Issue
Block a user