This commit is contained in:
Alex
2026-02-08 17:47:49 -06:00
parent 09117f9c62
commit 41dd6d9a64
7 changed files with 465 additions and 569 deletions

View File

@@ -179,7 +179,7 @@ This prevents messages from being overwritten when using Claude Desktop on multi
- **Timed wakes**: CMD+R ensures chat is synced before system check message - **Timed wakes**: CMD+R ensures chat is synced before system check message
- **Matrix wakes**: CMD+R ensures you see the latest conversation context - **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. The daemon waits **15 seconds** after sending CMD+R to ensure the refresh completes before sending the message.
**Why this matters:** **Why this matters:**
- Multi-device sync: If you're active on mobile/web, desktop chat stays current - Multi-device sync: If you're active on mobile/web, desktop chat stays current
@@ -277,14 +277,6 @@ def generate_message() -> str:
return f"Your custom message template with {timestamp}" 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 ## Monitoring
### Logs ### Logs
@@ -707,7 +699,7 @@ claude-desktop-automation/
⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool) ⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool)
⚠️ **Requires Accessibility** - Special macOS permissions for UI automation ⚠️ **Requires Accessibility** - Special macOS permissions for UI automation
⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically ⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically
⚠️ **Fixed refresh delay** - Waits 10 seconds after CMD+R (configurable if needed) ⚠️ **Fixed refresh delay** - Waits 15 seconds after CMD+R (configurable if needed)
## Future Plans ## Future Plans

79
matrix_helpers.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Matrix Helper Utilities
Shared utilities for Matrix client operations, reducing code duplication
across matrix_mcp.py and matrix_integration.py.
"""
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncIterator
try:
from nio import AsyncClient
except ImportError:
raise ImportError(
"matrix-nio not installed. Install with: pip3 install matrix-nio\n"
"Note: Without [e2e] extra, only unencrypted rooms are supported"
)
# Configuration
CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json"
MATRIX_DATA_DIR = Path.home() / ".matrix-data"
logger = logging.getLogger(__name__)
def load_credentials() -> dict:
"""Load Matrix credentials from JSON file"""
if not CREDENTIALS_FILE.exists():
raise FileNotFoundError(
f"Credentials file not found: {CREDENTIALS_FILE}\n"
f"Run setup_matrix.sh to create credentials"
)
try:
with open(CREDENTIALS_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load credentials: {e}")
raise
@asynccontextmanager
async def matrix_client() -> AsyncIterator[AsyncClient]:
"""
Async context manager for Matrix client operations.
Handles client setup, session restoration, and cleanup automatically.
Ensures client.close() is always called, preventing resource leaks.
Usage:
async with matrix_client() as client:
response = await client.room_send(...)
Yields:
AsyncClient: Configured and authenticated Matrix client
"""
creds = load_credentials()
# Create data directory for E2EE store
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session from credentials
client.access_token = creds['access_token']
client.device_id = creds['device_id']
try:
yield client
finally:
await client.close()

View File

@@ -449,7 +449,7 @@ class MatrixMonitor:
# Trigger wake if rate limit allows # Trigger wake if rate limit allows
await self._maybe_trigger_wake() await self._maybe_trigger_wake()
else: else:
logger.warning(f"Failed to download/compress image: {event.body}") logger.warning(f"Image rejected (too large or download failed): {event.body}")
except Exception as e: except Exception as e:
logger.error(f"Error processing image: {e}") logger.error(f"Error processing image: {e}")
@@ -596,7 +596,19 @@ class MatrixMonitor:
buffer = BytesIO() buffer = BytesIO()
final_img = img.resize((800, 600), Image.Resampling.LANCZOS) final_img = img.resize((800, 600), Image.Resampling.LANCZOS)
final_img.save(buffer, format='JPEG', quality=50, optimize=True) final_img.save(buffer, format='JPEG', quality=50, optimize=True)
return buffer.getvalue() final_data = buffer.getvalue()
# Final size validation - reject if still too large
final_base64 = base64.b64encode(final_data)
final_size_mb = len(final_base64) / (1024 * 1024)
if final_size_mb > target_size_mb:
logger.error(
f"Image still too large after all compression attempts: "
f"{final_size_mb:.2f}MB base64 (limit: {target_size_mb}MB)"
)
return None
return final_data
except Exception as e: except Exception as e:
logger.error(f"Compression failed: {e}") logger.error(f"Compression failed: {e}")

View File

@@ -33,6 +33,8 @@ from typing import List, Optional, Dict, Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.types import Image as MCPImage from mcp.server.fastmcp.utilities.types import Image as MCPImage
from matrix_helpers import matrix_client, load_credentials as load_credentials_helper
try: try:
from nio import ( from nio import (
AsyncClient, AsyncClient,
@@ -429,6 +431,7 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
from pathlib import Path from pathlib import Path
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
import aiohttp
# Resolve path # Resolve path
path = Path(file_path).expanduser().resolve() path = Path(file_path).expanduser().resolve()
@@ -455,27 +458,13 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
width, height = None, None width, height = None, None
mime_type = 'image/jpeg' mime_type = 'image/jpeg'
# Load credentials # Load credentials for upload URL
creds = load_credentials() creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload image to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
filename = path.name filename = path.name
logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)")
import aiohttp # Upload image to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
headers = { headers = {
"Authorization": f"Bearer {creds['access_token']}", "Authorization": f"Bearer {creds['access_token']}",
@@ -490,11 +479,10 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
logger.info(f"Upload successful: {content_uri}") logger.info(f"Upload successful: {content_uri}")
else: else:
error_text = await resp.text() error_text = await resp.text()
await client.close()
logger.error(f"Upload failed: {resp.status} - {error_text}") logger.error(f"Upload failed: {resp.status} - {error_text}")
return f"Error uploading image: {resp.status} - {error_text}" return f"Error uploading image: {resp.status} - {error_text}"
# Send image message # Send image message using context manager
content = { content = {
"msgtype": "m.image", "msgtype": "m.image",
"body": filename, "body": filename,
@@ -509,22 +497,21 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
content["info"]["w"] = width content["info"]["w"] = width
content["info"]["h"] = height content["info"]["h"] = height
response = await client.room_send( async with matrix_client() as client:
room_id=room_id, response = await client.room_send(
message_type="m.room.message", room_id=room_id,
content=content message_type="m.room.message",
) content=content
)
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent image to {room_id}: {filename}")
if isinstance(response, RoomSendResponse): return f"✓ Image sent to room: {filename} (event ID: {response.event_id})"
logger.info(f"Sent image to {room_id}: {filename}") elif isinstance(response, RoomSendError):
return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" logger.error(f"Failed to send image: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send image: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send image from file: {e}") logger.error(f"Failed to send image from file: {e}")
@@ -554,6 +541,7 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
try: try:
from pathlib import Path from pathlib import Path
import wave import wave
import aiohttp
# Resolve path # Resolve path
path = Path(file_path).expanduser().resolve() path = Path(file_path).expanduser().resolve()
@@ -595,27 +583,13 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
except Exception as e: except Exception as e:
logger.warning(f"Could not extract WAV duration: {e}") logger.warning(f"Could not extract WAV duration: {e}")
# Load credentials # Load credentials for upload URL
creds = load_credentials() creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload audio to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
filename = path.name filename = path.name
logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)")
import aiohttp # Upload audio to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
headers = { headers = {
"Authorization": f"Bearer {creds['access_token']}", "Authorization": f"Bearer {creds['access_token']}",
@@ -630,11 +604,10 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
logger.info(f"Upload successful: {content_uri}") logger.info(f"Upload successful: {content_uri}")
else: else:
error_text = await resp.text() error_text = await resp.text()
await client.close()
logger.error(f"Upload failed: {resp.status} - {error_text}") logger.error(f"Upload failed: {resp.status} - {error_text}")
return f"Error uploading audio: {resp.status} - {error_text}" return f"Error uploading audio: {resp.status} - {error_text}"
# Send voice message # Send voice message using context manager
content = { content = {
"msgtype": "m.audio", "msgtype": "m.audio",
"body": filename, "body": filename,
@@ -649,22 +622,21 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
if duration_ms: if duration_ms:
content["info"]["duration"] = duration_ms content["info"]["duration"] = duration_ms
response = await client.room_send( async with matrix_client() as client:
room_id=room_id, response = await client.room_send(
message_type="m.room.message", room_id=room_id,
content=content message_type="m.room.message",
) content=content
)
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent voice message to {room_id}: {filename}")
if isinstance(response, RoomSendResponse): return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})"
logger.info(f"Sent voice message to {room_id}: {filename}") elif isinstance(response, RoomSendError):
return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" logger.error(f"Failed to send voice message: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send voice message: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send voice message from file: {e}") logger.error(f"Failed to send voice message from file: {e}")
@@ -713,21 +685,6 @@ async def matrix_send_message(room_id: str, message: str) -> str:
matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*") matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Convert markdown to HTML # Convert markdown to HTML
html_body = _convert_markdown_to_html(message) html_body = _convert_markdown_to_html(message)
@@ -742,23 +699,21 @@ async def matrix_send_message(room_id: str, message: str) -> str:
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html_body content["formatted_body"] = html_body
# Send message async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent message to {room_id}: {message[:50]}...")
if isinstance(response, RoomSendResponse): return f"✓ Message sent to room (event ID: {response.event_id})"
logger.info(f"Sent message to {room_id}: {message[:50]}...") elif isinstance(response, RoomSendError):
return f"✓ Message sent to room (event ID: {response.event_id})" logger.error(f"Failed to send message: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send message: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send message: {e}") logger.error(f"Failed to send message: {e}")
@@ -785,21 +740,6 @@ async def matrix_send_emote(room_id: str, action: str) -> str:
matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully") matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Convert markdown to HTML # Convert markdown to HTML
html_body = _convert_markdown_to_html(action) html_body = _convert_markdown_to_html(action)
@@ -814,23 +754,21 @@ async def matrix_send_emote(room_id: str, action: str) -> str:
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html_body content["formatted_body"] = html_body
# Send message async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent emote to {room_id}: {action[:50]}...")
if isinstance(response, RoomSendResponse): return f"✓ Emote sent to room (event ID: {response.event_id})"
logger.info(f"Sent emote to {room_id}: {action[:50]}...") elif isinstance(response, RoomSendError):
return f"✓ Emote sent to room (event ID: {response.event_id})" logger.error(f"Failed to send emote: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send emote: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send emote: {e}") logger.error(f"Failed to send emote: {e}")
@@ -893,7 +831,7 @@ def get_matrix_status() -> str:
last_wake = datetime.fromisoformat(last_wake_str) last_wake = datetime.fromisoformat(last_wake_str)
time_ago = datetime.now() - last_wake time_ago = datetime.now() - last_wake
status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
except: except Exception:
status_lines.append(f"Last Matrix wake: {last_wake_str}") status_lines.append(f"Last Matrix wake: {last_wake_str}")
else: else:
status_lines.append("Last Matrix wake: Never") status_lines.append("Last Matrix wake: Never")
@@ -908,7 +846,7 @@ def get_matrix_status() -> str:
else: else:
remaining = 120 - elapsed remaining = 120 - elapsed
status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)") status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)")
except: except Exception:
status_lines.append("Rate limit: Unknown") status_lines.append("Rate limit: Unknown")
else: else:
status_lines.append("Rate limit: OK (can wake)") status_lines.append("Rate limit: OK (can wake)")
@@ -1043,39 +981,23 @@ async def join_matrix_room(room_id: str) -> str:
join_matrix_room("!abc123:matrix.org") join_matrix_room("!abc123:matrix.org")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Join room
logger.info(f"Attempting to join room: {room_id}") logger.info(f"Attempting to join room: {room_id}")
response = await client.join(room_id)
await client.close() async with matrix_client() as client:
response = await client.join(room_id)
if isinstance(response, JoinResponse): if isinstance(response, JoinResponse):
logger.info(f"Successfully joined room: {room_id}") logger.info(f"Successfully joined room: {room_id}")
# Mark invite as processed # Mark invite as processed
_mark_invites_processed_internal([room_id]) _mark_invites_processed_internal([room_id])
return f"✓ Joined room: {room_id}" return f"✓ Joined room: {room_id}"
elif isinstance(response, JoinError): elif isinstance(response, JoinError):
logger.error(f"Failed to join room: {response.message}") logger.error(f"Failed to join room: {response.message}")
return f"Error: {response.message}" return f"Error: {response.message}"
else: else:
return f"Error: Unexpected response type" return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to join room: {e}") logger.error(f"Failed to join room: {e}")
@@ -1099,35 +1021,19 @@ async def leave_matrix_room(room_id: str) -> str:
leave_matrix_room("!abc123:matrix.org") leave_matrix_room("!abc123:matrix.org")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Leave room
logger.info(f"Attempting to leave room: {room_id}") logger.info(f"Attempting to leave room: {room_id}")
response = await client.room_leave(room_id)
await client.close() async with matrix_client() as client:
response = await client.room_leave(room_id)
if isinstance(response, RoomLeaveResponse): if isinstance(response, RoomLeaveResponse):
logger.info(f"Successfully left room: {room_id}") logger.info(f"Successfully left room: {room_id}")
return f"✓ Left room: {room_id}" return f"✓ Left room: {room_id}"
elif isinstance(response, RoomLeaveError): elif isinstance(response, RoomLeaveError):
logger.error(f"Failed to leave room: {response.message}") logger.error(f"Failed to leave room: {response.message}")
return f"Error: {response.message}" return f"Error: {response.message}"
else: else:
return f"Error: Unexpected response type" return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to leave room: {e}") logger.error(f"Failed to leave room: {e}")
@@ -1145,44 +1051,30 @@ async def list_matrix_rooms() -> str:
Formatted list of rooms Formatted list of rooms
""" """
try: try:
# Load credentials # Get room whitelist from credentials
creds = load_credentials() creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms
await client.sync(timeout=30000)
# Get room whitelist
whitelist = set(creds.get('room_whitelist', [])) whitelist = set(creds.get('room_whitelist', []))
# Format room list async with matrix_client() as client:
rooms_lines = ["Matrix Rooms:"] # Sync to get rooms
rooms_lines.append("=" * 60) await client.sync(timeout=30000)
for room_id, room in client.rooms.items(): # Format room list
# Check if room is whitelisted rooms_lines = ["Matrix Rooms:"]
monitored = "" if (not whitelist or room_id in whitelist) else "" rooms_lines.append("=" * 60)
rooms_lines.append(f"{monitored} {room.display_name or room.room_id}") room_count = len(client.rooms)
rooms_lines.append(f" ID: {room_id}") for room_id, room in client.rooms.items():
rooms_lines.append(f" Members: {len(room.users)}") # Check if room is whitelisted
rooms_lines.append("") monitored = "" if (not whitelist or room_id in whitelist) else ""
await client.close() rooms_lines.append(f"{monitored} {room.display_name or room.room_id}")
rooms_lines.append(f" ID: {room_id}")
rooms_lines.append(f" Members: {len(room.users)}")
rooms_lines.append("")
logger.info(f"Listed {len(client.rooms)} Matrix rooms") logger.info(f"Listed {room_count} Matrix rooms")
return "\n".join(rooms_lines) return "\n".join(rooms_lines)
except Exception as e: except Exception as e:
logger.error(f"Failed to list rooms: {e}") logger.error(f"Failed to list rooms: {e}")
@@ -1208,21 +1100,6 @@ async def matrix_reply_to_message(room_id: str, event_id: str, message: str) ->
reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!") reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Send threaded reply # Send threaded reply
content = { content = {
"msgtype": "m.text", "msgtype": "m.text",
@@ -1234,22 +1111,21 @@ async def matrix_reply_to_message(room_id: str, event_id: str, message: str) ->
} }
} }
response = await client.room_send( async with matrix_client() as client:
room_id=room_id, response = await client.room_send(
message_type="m.room.message", room_id=room_id,
content=content message_type="m.room.message",
) content=content
)
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent reply to {event_id} in {room_id}")
if isinstance(response, RoomSendResponse): return f"✓ Reply sent (event ID: {response.event_id})"
logger.info(f"Sent reply to {event_id} in {room_id}") elif isinstance(response, RoomSendError):
return f"✓ Reply sent (event ID: {response.event_id})" logger.error(f"Failed to send reply: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send reply: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send reply: {e}") logger.error(f"Failed to send reply: {e}")
@@ -1274,21 +1150,6 @@ async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> st
react_to_message("!abc:matrix.org", "$event123", "❤️") react_to_message("!abc:matrix.org", "$event123", "❤️")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Send reaction # Send reaction
content = { content = {
"m.relates_to": { "m.relates_to": {
@@ -1298,22 +1159,21 @@ async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> st
} }
} }
response = await client.room_send( async with matrix_client() as client:
room_id=room_id, response = await client.room_send(
message_type="m.reaction", room_id=room_id,
content=content message_type="m.reaction",
) content=content
)
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent reaction {emoji} to {event_id}")
if isinstance(response, RoomSendResponse): return f"✓ Reacted with {emoji}"
logger.info(f"Sent reaction {emoji} to {event_id}") elif isinstance(response, RoomSendError):
return f"✓ Reacted with {emoji}" logger.error(f"Failed to send reaction: {response.message}")
elif isinstance(response, RoomSendError): return f"Error: {response.message}"
logger.error(f"Failed to send reaction: {response.message}") else:
return f"Error: {response.message}" return f"Error: Unexpected response type"
else:
return f"Error: Unexpected response type"
except Exception as e: except Exception as e:
logger.error(f"Failed to send reaction: {e}") logger.error(f"Failed to send reaction: {e}")
@@ -1337,43 +1197,27 @@ async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]:
get_user_profile("@friend:matrix.org") get_user_profile("@friend:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials() # Get display name
display_name_response = await client.get_displayname(user_id)
display_name = None
if isinstance(display_name_response, ProfileGetDisplayNameResponse):
display_name = display_name_response.displayname
# Create Matrix client # Get avatar
MATRIX_DATA_DIR.mkdir(exist_ok=True) avatar_response = await client.get_avatar(user_id)
client = AsyncClient( avatar_url = None
homeserver=creds['homeserver'], if isinstance(avatar_response, ProfileGetAvatarResponse):
user=creds['user_id'], avatar_url = avatar_response.avatar_url
store_path=str(MATRIX_DATA_DIR),
)
# Restore session profile = {
client.access_token = creds['access_token'] "user_id": user_id,
client.device_id = creds['device_id'] "display_name": display_name or user_id,
"avatar_url": avatar_url,
}
# Get display name logger.info(f"Retrieved profile for {user_id}")
display_name_response = await client.get_displayname(user_id) return profile
display_name = None
if isinstance(display_name_response, ProfileGetDisplayNameResponse):
display_name = display_name_response.displayname
# Get avatar
avatar_response = await client.get_avatar(user_id)
avatar_url = None
if isinstance(avatar_response, ProfileGetAvatarResponse):
avatar_url = avatar_response.avatar_url
await client.close()
profile = {
"user_id": user_id,
"display_name": display_name or user_id,
"avatar_url": avatar_url,
}
logger.info(f"Retrieved profile for {user_id}")
return profile
except Exception as e: except Exception as e:
logger.error(f"Failed to get user profile: {e}") logger.error(f"Failed to get user profile: {e}")
@@ -1403,28 +1247,11 @@ async def matrix_set_presence(status: str, message: str = "") -> str:
if status not in valid_statuses: if status not in valid_statuses:
return f"Error: status must be one of {valid_statuses}" return f"Error: status must be one of {valid_statuses}"
# Load credentials async with matrix_client() as client:
creds = load_credentials() await client.set_presence(status, message if message else None)
# Create Matrix client logger.info(f"Set presence to {status}")
MATRIX_DATA_DIR.mkdir(exist_ok=True) return f"✓ Presence set to {status}" + (f": {message}" if message else "")
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Set presence
await client.set_presence(status, message if message else None)
await client.close()
logger.info(f"Set presence to {status}")
return f"✓ Presence set to {status}" + (f": {message}" if message else "")
except Exception as e: except Exception as e:
logger.error(f"Failed to set presence: {e}") logger.error(f"Failed to set presence: {e}")
@@ -1482,58 +1309,42 @@ async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: st
if not mime_type: if not mime_type:
mime_type = 'application/octet-stream' mime_type = 'application/octet-stream'
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload file
logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)") logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)")
upload_response = await client.upload(
data_provider=lambda *args: file_data,
content_type=mime_type,
filename=filename,
filesize=len(file_data)
)
if isinstance(upload_response, UploadError): async with matrix_client() as client:
await client.close() # Upload file
return f"Error uploading file: {upload_response.message}" upload_response = await client.upload(
data_provider=lambda *args: file_data,
content_type=mime_type,
filename=filename,
filesize=len(file_data)
)
# Send file message if isinstance(upload_response, UploadError):
content = { return f"Error uploading file: {upload_response.message}"
"msgtype": "m.file",
"body": filename, # Send file message
"url": upload_response.content_uri, content = {
"info": { "msgtype": "m.file",
"mimetype": mime_type, "body": filename,
"size": len(file_data), "url": upload_response.content_uri,
"info": {
"mimetype": mime_type,
"size": len(file_data),
}
} }
}
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close() if isinstance(response, RoomSendResponse):
logger.info(f"Sent file to {room_id}: {filename}")
if isinstance(response, RoomSendResponse): return f"✓ File sent: {filename} (event ID: {response.event_id})"
logger.info(f"Sent file to {room_id}: {filename}") else:
return f" File sent: {filename} (event ID: {response.event_id})" return f"Error: Failed to send file"
else:
return f"Error: Failed to send file"
except Exception as e: except Exception as e:
logger.error(f"Failed to send file: {e}") logger.error(f"Failed to send file: {e}")
@@ -1619,44 +1430,27 @@ async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]:
get_room_members("!abc:matrix.org") get_room_members("!abc:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials() # Sync to get rooms
await client.sync(timeout=30000)
# Create Matrix client # Find the room
MATRIX_DATA_DIR.mkdir(exist_ok=True) if room_id not in client.rooms:
client = AsyncClient( return [{"error": f"Room not found: {room_id}"}]
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session room = client.rooms[room_id]
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms # Get members
await client.sync(timeout=30000) members = []
for user_id, member in room.users.items():
members.append({
"user_id": user_id,
"display_name": member.display_name or user_id,
"power_level": member.power_level,
})
# Find the room logger.info(f"Retrieved {len(members)} members from {room_id}")
if room_id not in client.rooms: return members
await client.close()
return [{"error": f"Room not found: {room_id}"}]
room = client.rooms[room_id]
# Get members
members = []
for user_id, member in room.users.items():
members.append({
"user_id": user_id,
"display_name": member.display_name or user_id,
"power_level": member.power_level,
})
await client.close()
logger.info(f"Retrieved {len(members)} members from {room_id}")
return members
except Exception as e: except Exception as e:
logger.error(f"Failed to get room members: {e}") logger.error(f"Failed to get room members: {e}")
@@ -1681,32 +1475,15 @@ async def matrix_set_room_topic(room_id: str, topic: str) -> str:
set_room_topic("!abc:matrix.org", "Discussion about AI projects") set_room_topic("!abc:matrix.org", "Discussion about AI projects")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials() await client.room_put_state(
room_id=room_id,
event_type="m.room.topic",
content={"topic": topic}
)
# Create Matrix client logger.info(f"Set topic for {room_id}: {topic[:50]}...")
MATRIX_DATA_DIR.mkdir(exist_ok=True) return f"✓ Room topic updated"
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Set room topic
await client.room_put_state(
room_id=room_id,
event_type="m.room.topic",
content={"topic": topic}
)
await client.close()
logger.info(f"Set topic for {room_id}: {topic[:50]}...")
return f"✓ Room topic updated"
except Exception as e: except Exception as e:
logger.error(f"Failed to set room topic: {e}") logger.error(f"Failed to set room topic: {e}")
@@ -1730,45 +1507,28 @@ async def matrix_get_room_state(room_id: str) -> Dict[str, Any]:
get_room_state("!abc:matrix.org") get_room_state("!abc:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials() # Sync to get rooms
await client.sync(timeout=30000)
# Create Matrix client # Find the room
MATRIX_DATA_DIR.mkdir(exist_ok=True) if room_id not in client.rooms:
client = AsyncClient( return {"error": f"Room not found: {room_id}"}
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session room = client.rooms[room_id]
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms # Build state dict
await client.sync(timeout=30000) state = {
"room_id": room_id,
"name": room.name or room.display_name or room_id,
"topic": room.topic,
"member_count": len(room.users),
"encrypted": room.encrypted,
"room_version": getattr(room, 'room_version', 'unknown'),
}
# Find the room logger.info(f"Retrieved state for {room_id}")
if room_id not in client.rooms: return state
await client.close()
return {"error": f"Room not found: {room_id}"}
room = client.rooms[room_id]
# Build state dict
state = {
"room_id": room_id,
"name": room.name or room.display_name or room_id,
"topic": room.topic,
"member_count": len(room.users),
"encrypted": room.encrypted,
"room_version": getattr(room, 'room_version', 'unknown'),
}
await client.close()
logger.info(f"Retrieved state for {room_id}")
return state
except Exception as e: except Exception as e:
logger.error(f"Failed to get room state: {e}") logger.error(f"Failed to get room state: {e}")
@@ -1794,48 +1554,32 @@ async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> L
search_messages("!abc:matrix.org", "meeting", limit=5) search_messages("!abc:matrix.org", "meeting", limit=5)
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials() # Get room messages
response = await client.room_messages(
room_id=room_id,
start="",
limit=100 # Search last 100 messages
)
# Create Matrix client results = []
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session if isinstance(response, RoomMessagesResponse):
client.access_token = creds['access_token'] # Search through messages
client.device_id = creds['device_id'] for event in response.chunk:
if hasattr(event, 'body') and query.lower() in event.body.lower():
results.append({
"sender": event.sender,
"message": event.body,
"timestamp": datetime.fromtimestamp(event.server_timestamp / 1000).isoformat(),
"event_id": event.event_id,
})
# Get room messages if len(results) >= limit:
response = await client.room_messages( break
room_id=room_id,
start="",
limit=100 # Search last 100 messages
)
results = [] logger.info(f"Found {len(results)} messages matching '{query}'")
return results
if isinstance(response, RoomMessagesResponse):
# Search through messages
for event in response.chunk:
if hasattr(event, 'body') and query.lower() in event.body.lower():
results.append({
"sender": event.sender,
"message": event.body,
"timestamp": datetime.fromtimestamp(event.server_timestamp / 1000).isoformat(),
"event_id": event.event_id,
})
if len(results) >= limit:
break
await client.close()
logger.info(f"Found {len(results)} messages matching '{query}'")
return results
except Exception as e: except Exception as e:
logger.error(f"Failed to search messages: {e}") logger.error(f"Failed to search messages: {e}")

View File

@@ -81,7 +81,7 @@ def send_refresh(delay_seconds=REFRESH_DELAY_SECONDS):
Send CMD+R to refresh and wait for completion Send CMD+R to refresh and wait for completion
Args: Args:
delay_seconds: Time to wait after CMD+R (default: 10) delay_seconds: Time to wait after CMD+R (default: 15)
Returns: Returns:
bool: True if successful bool: True if successful

View File

@@ -9,11 +9,15 @@ Day 83: Added movement tracking to filter static false positives (posters!)
""" """
import json import json
import logging
import sqlite3 import sqlite3
import requests import requests
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
# Setup logging
logger = logging.getLogger("vixy_status")
# Service endpoints # Service endpoints
ENVIRO_URL = "http://eye1.local:8767" ENVIRO_URL = "http://eye1.local:8767"
OAK_URL = "http://head-vixy.local:8100" OAK_URL = "http://head-vixy.local:8100"
@@ -90,8 +94,8 @@ def get_enviro_status() -> str:
humidity = data.get('humidity', 0) humidity = data.get('humidity', 0)
light = data.get('light', 0) light = data.get('light', 0)
return f"Basement: {temp_f:.1f}F, {humidity:.1f}% humidity, {light:.1f} lux" return f"Basement: {temp_f:.1f}F, {humidity:.1f}% humidity, {light:.1f} lux"
except Exception: except Exception as e:
pass logger.warning(f"Enviro service unavailable: {e}")
return "Basement: sensors unavailable" return "Basement: sensors unavailable"
@@ -139,8 +143,8 @@ def get_presence_status() -> str:
return f"Foxy: away (last seen {last_seen:.0f}s ago)" return f"Foxy: away (last seen {last_seen:.0f}s ago)"
else: else:
return "Foxy: away" return "Foxy: away"
except Exception: except Exception as e:
pass logger.warning(f"OAK-D presence service unavailable: {e}")
return None # Return None to omit line if camera unavailable return None # Return None to omit line if camera unavailable
@@ -167,8 +171,8 @@ def get_sound_status() -> str:
return f"{category} ({top_score}% {classes_str})" return f"{category} ({top_score}% {classes_str})"
else: else:
return f"{category}" return f"{category}"
except Exception: except Exception as e:
pass logger.warning(f"Headmic sound service unavailable: {e}")
return None # Return None to omit line if service unavailable return None # Return None to omit line if service unavailable
@@ -186,52 +190,117 @@ def get_matrix_status() -> str:
return f"Matrix: {len(unprocessed)} new message(s)" return f"Matrix: {len(unprocessed)} new message(s)"
else: else:
return "Matrix: no new messages" return "Matrix: no new messages"
except Exception: except Exception as e:
pass logger.warning(f"Matrix status unavailable: {e}")
return "Matrix: status unavailable" return "Matrix: status unavailable"
def get_vision_status() -> str: def get_vision_status() -> str:
"""Get vision/motion event status from SQLite database""" """Get vision/motion event status from SQLite database.
Uses object detection data when available to show what was seen
(person, cat, etc.) rather than just raw motion counts.
"""
try: try:
if not EVENTS_DB.exists(): if not EVENTS_DB.exists():
return "Vision: no events database" return "no events database"
conn = sqlite3.connect(str(EVENTS_DB))
conn.row_factory = sqlite3.Row
# Get events from last 2 hours # Get events from last 2 hours
cutoff = (datetime.now() - timedelta(hours=2)).isoformat() cutoff = (datetime.now() - timedelta(hours=2)).isoformat()
cursor = conn.execute( with sqlite3.connect(str(EVENTS_DB)) as conn:
"""SELECT camera_id, annotation FROM events conn.row_factory = sqlite3.Row
WHERE timestamp > ? # Check if detections column exists
ORDER BY timestamp DESC""", columns = [row[1] for row in conn.execute("PRAGMA table_info(events)").fetchall()]
(cutoff,) has_detections = "detections" in columns
)
events = cursor.fetchall() if has_detections:
conn.close() cursor = conn.execute(
"""SELECT camera_id, event_type, detections, timestamp FROM events
WHERE timestamp > ?
ORDER BY timestamp DESC""",
(cutoff,)
)
else:
cursor = conn.execute(
"""SELECT camera_id, event_type, NULL as detections, timestamp FROM events
WHERE timestamp > ?
ORDER BY timestamp DESC""",
(cutoff,)
)
events = cursor.fetchall()
if not events: if not events:
return "Vision: no recent motion" return "no recent activity"
# Count labels per camera, track most recent detection
# Structure: {camera: {label: count}}
camera_labels = {}
camera_motion_only = {}
latest_detection = None # (label, camera, timestamp)
# Count by camera
by_camera = {}
unannotated = 0
for event in events: for event in events:
cam = event['camera_id'] or 'unknown' cam = event['camera_id'] or 'unknown'
by_camera[cam] = by_camera.get(cam, 0) + 1
if not event['annotation']:
unannotated += 1
total = len(events) # Parse detections JSON
camera_breakdown = ", ".join(f"{cam}: {count}" for cam, count in by_camera.items()) dets = None
if event['detections']:
try:
dets = json.loads(event['detections'])
except (json.JSONDecodeError, TypeError):
pass
return f"{camera_breakdown}" if dets:
if cam not in camera_labels:
camera_labels[cam] = {}
for d in dets:
label = d.get('label', 'unknown')
camera_labels[cam][label] = camera_labels[cam].get(label, 0) + 1
# Track most recent detection (first one since ordered DESC)
if latest_detection is None:
latest_detection = (dets[0].get('label', 'unknown'), cam, event['timestamp'])
else:
camera_motion_only[cam] = camera_motion_only.get(cam, 0) + 1
# Format per-camera summaries
cam_parts = []
all_cams = sorted(set(list(camera_labels.keys()) + list(camera_motion_only.keys())))
for cam in all_cams:
labels = camera_labels.get(cam, {})
motion_count = camera_motion_only.get(cam, 0)
parts = []
if labels:
# Sort by count descending
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
parts.append(f"{count} {label}")
if motion_count:
parts.append(f"{motion_count} motion")
cam_parts.append(f"{cam}: {', '.join(parts)}")
result = " | ".join(cam_parts)
# Add "last seen" for most recent detection
if latest_detection:
label, cam, ts = latest_detection
try:
event_time = datetime.fromisoformat(ts.replace('Z', '+00:00'))
now = datetime.now(event_time.tzinfo)
mins_ago = int((now - event_time).total_seconds() / 60)
if mins_ago < 1:
result += f" (last: {label} in {cam}, just now)"
else:
result += f" (last: {label} in {cam}, {mins_ago}m ago)"
except Exception:
result += f" (last: {label} in {cam})"
return result
except Exception as e: except Exception as e:
return f"Vision: error ({e})" return f"error ({e})"
def format_status_for_wakeup() -> str: def format_status_for_wakeup() -> str:

View File

@@ -221,7 +221,7 @@ def get_status() -> str:
last_wake = datetime.fromisoformat(state['last_wake']) last_wake = datetime.fromisoformat(state['last_wake'])
time_ago = datetime.now() - 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)") status_lines.append(f"Last wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
except: except Exception:
status_lines.append(f"Last wake: {state.get('last_wake')}") status_lines.append(f"Last wake: {state.get('last_wake')}")
else: else:
status_lines.append("Last wake: Never (daemon just started)") status_lines.append("Last wake: Never (daemon just started)")
@@ -235,7 +235,7 @@ def get_status() -> str:
status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)") status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)")
else: else:
status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})") status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})")
except: except Exception:
status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}") status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}")
else: else:
interval = state.get('interval_minutes', 60) interval = state.get('interval_minutes', 60)