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