diff --git a/README.md b/README.md index 7687fae..1520d4d 100755 --- a/README.md +++ b/README.md @@ -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 - **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:** - 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}" ``` -### 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 @@ -707,7 +699,7 @@ claude-desktop-automation/ ⚠️ **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) +⚠️ **Fixed refresh delay** - Waits 15 seconds after CMD+R (configurable if needed) ## Future Plans diff --git a/matrix_helpers.py b/matrix_helpers.py new file mode 100644 index 0000000..68e7b32 --- /dev/null +++ b/matrix_helpers.py @@ -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() diff --git a/matrix_integration.py b/matrix_integration.py index 2f9d43f..0ad42eb 100755 --- a/matrix_integration.py +++ b/matrix_integration.py @@ -449,7 +449,7 @@ class MatrixMonitor: # Trigger wake if rate limit allows await self._maybe_trigger_wake() 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: logger.error(f"Error processing image: {e}") @@ -596,7 +596,19 @@ class MatrixMonitor: buffer = BytesIO() final_img = img.resize((800, 600), Image.Resampling.LANCZOS) 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: logger.error(f"Compression failed: {e}") diff --git a/matrix_mcp.py b/matrix_mcp.py index 18c33de..f3a163b 100755 --- a/matrix_mcp.py +++ b/matrix_mcp.py @@ -33,6 +33,8 @@ from typing import List, Optional, Dict, Any from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.utilities.types import Image as MCPImage +from matrix_helpers import matrix_client, load_credentials as load_credentials_helper + try: from nio import ( AsyncClient, @@ -429,6 +431,7 @@ async def matrix_send_image(room_id: str, file_path: str) -> str: from pathlib import Path from io import BytesIO from PIL import Image + import aiohttp # Resolve path path = Path(file_path).expanduser().resolve() @@ -455,33 +458,19 @@ async def matrix_send_image(room_id: str, file_path: str) -> str: width, height = None, None mime_type = 'image/jpeg' - # Load credentials + # Load credentials for upload URL 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'] + filename = path.name + logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") # Upload image to Matrix homeserver using aiohttp directly # (nio's upload has timeout issues) - filename = path.name - logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") - - import aiohttp upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" headers = { "Authorization": f"Bearer {creds['access_token']}", "Content-Type": mime_type, } - + async with aiohttp.ClientSession() as session: async with session.post(upload_url, data=image_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: if resp.status == 200: @@ -490,11 +479,10 @@ async def matrix_send_image(room_id: str, file_path: str) -> str: logger.info(f"Upload successful: {content_uri}") else: error_text = await resp.text() - await client.close() logger.error(f"Upload failed: {resp.status} - {error_text}") return f"Error uploading image: {resp.status} - {error_text}" - # Send image message + # Send image message using context manager content = { "msgtype": "m.image", "body": filename, @@ -509,22 +497,21 @@ async def matrix_send_image(room_id: str, file_path: str) -> str: content["info"]["w"] = width content["info"]["h"] = height - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent image to {room_id}: {filename}") - return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send image: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent image to {room_id}: {filename}") + return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send image: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send image from file: {e}") @@ -554,6 +541,7 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str: try: from pathlib import Path import wave + import aiohttp # Resolve path path = Path(file_path).expanduser().resolve() @@ -595,33 +583,19 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str: except Exception as e: logger.warning(f"Could not extract WAV duration: {e}") - # Load credentials + # Load credentials for upload URL 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'] + filename = path.name + logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") # Upload audio to Matrix homeserver using aiohttp directly # (nio's upload has timeout issues) - filename = path.name - logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") - - import aiohttp upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" headers = { "Authorization": f"Bearer {creds['access_token']}", "Content-Type": mime_type, } - + async with aiohttp.ClientSession() as session: async with session.post(upload_url, data=audio_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: if resp.status == 200: @@ -630,11 +604,10 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str: logger.info(f"Upload successful: {content_uri}") else: error_text = await resp.text() - await client.close() logger.error(f"Upload failed: {resp.status} - {error_text}") return f"Error uploading audio: {resp.status} - {error_text}" - # Send voice message + # Send voice message using context manager content = { "msgtype": "m.audio", "body": filename, @@ -649,22 +622,21 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str: if duration_ms: content["info"]["duration"] = duration_ms - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent voice message to {room_id}: {filename}") - return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send voice message: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent voice message to {room_id}: {filename}") + return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send voice message: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send voice message from file: {e}") @@ -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*") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - # Convert markdown to HTML html_body = _convert_markdown_to_html(message) @@ -742,23 +699,21 @@ async def matrix_send_message(room_id: str, message: str) -> str: content["format"] = "org.matrix.custom.html" content["formatted_body"] = html_body - # Send message - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent message to {room_id}: {message[:50]}...") - return f"✓ Message sent to room (event ID: {response.event_id})" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send message: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent message to {room_id}: {message[:50]}...") + return f"✓ Message sent to room (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send message: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send message: {e}") @@ -785,21 +740,6 @@ async def matrix_send_emote(room_id: str, action: str) -> str: matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - # Convert markdown to HTML html_body = _convert_markdown_to_html(action) @@ -814,23 +754,21 @@ async def matrix_send_emote(room_id: str, action: str) -> str: content["format"] = "org.matrix.custom.html" content["formatted_body"] = html_body - # Send message - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent emote to {room_id}: {action[:50]}...") - return f"✓ Emote sent to room (event ID: {response.event_id})" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send emote: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent emote to {room_id}: {action[:50]}...") + return f"✓ Emote sent to room (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send emote: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send emote: {e}") @@ -893,7 +831,7 @@ def get_matrix_status() -> str: last_wake = datetime.fromisoformat(last_wake_str) time_ago = datetime.now() - last_wake status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") - except: + except Exception: status_lines.append(f"Last Matrix wake: {last_wake_str}") else: status_lines.append("Last Matrix wake: Never") @@ -908,7 +846,7 @@ def get_matrix_status() -> str: else: remaining = 120 - elapsed status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)") - except: + except Exception: status_lines.append("Rate limit: Unknown") else: 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") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Join room logger.info(f"Attempting to join room: {room_id}") - response = await client.join(room_id) - await client.close() + async with matrix_client() as client: + response = await client.join(room_id) - if isinstance(response, JoinResponse): - logger.info(f"Successfully joined room: {room_id}") + if isinstance(response, JoinResponse): + logger.info(f"Successfully joined room: {room_id}") - # Mark invite as processed - _mark_invites_processed_internal([room_id]) + # Mark invite as processed + _mark_invites_processed_internal([room_id]) - return f"✓ Joined room: {room_id}" - elif isinstance(response, JoinError): - logger.error(f"Failed to join room: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + return f"✓ Joined room: {room_id}" + elif isinstance(response, JoinError): + logger.error(f"Failed to join room: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to join room: {e}") @@ -1099,35 +1021,19 @@ async def leave_matrix_room(room_id: str) -> str: leave_matrix_room("!abc123:matrix.org") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Leave room logger.info(f"Attempting to leave room: {room_id}") - response = await client.room_leave(room_id) - await client.close() + async with matrix_client() as client: + response = await client.room_leave(room_id) - if isinstance(response, RoomLeaveResponse): - logger.info(f"Successfully left room: {room_id}") - return f"✓ Left room: {room_id}" - elif isinstance(response, RoomLeaveError): - logger.error(f"Failed to leave room: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomLeaveResponse): + logger.info(f"Successfully left room: {room_id}") + return f"✓ Left room: {room_id}" + elif isinstance(response, RoomLeaveError): + logger.error(f"Failed to leave room: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to leave room: {e}") @@ -1145,44 +1051,30 @@ async def list_matrix_rooms() -> str: Formatted list of rooms """ try: - # Load credentials + # Get room whitelist from credentials creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Sync to get rooms - await client.sync(timeout=30000) - - # Get room whitelist whitelist = set(creds.get('room_whitelist', [])) - # Format room list - rooms_lines = ["Matrix Rooms:"] - rooms_lines.append("=" * 60) + async with matrix_client() as client: + # Sync to get rooms + await client.sync(timeout=30000) - for room_id, room in client.rooms.items(): - # Check if room is whitelisted - monitored = "✓" if (not whitelist or room_id in whitelist) else "✗" + # Format room list + rooms_lines = ["Matrix Rooms:"] + rooms_lines.append("=" * 60) - 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("") + room_count = len(client.rooms) + for room_id, room in client.rooms.items(): + # Check if room is whitelisted + 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") - return "\n".join(rooms_lines) + logger.info(f"Listed {room_count} Matrix rooms") + return "\n".join(rooms_lines) except Exception as 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!") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - # Send threaded reply content = { "msgtype": "m.text", @@ -1234,22 +1111,21 @@ async def matrix_reply_to_message(room_id: str, event_id: str, message: str) -> } } - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent reply to {event_id} in {room_id}") - return f"✓ Reply sent (event ID: {response.event_id})" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send reply: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent reply to {event_id} in {room_id}") + return f"✓ Reply sent (event ID: {response.event_id})" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send reply: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send reply: {e}") @@ -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", "❤️") """ try: - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - # Send reaction content = { "m.relates_to": { @@ -1298,22 +1159,21 @@ async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> st } } - response = await client.room_send( - room_id=room_id, - message_type="m.reaction", - content=content - ) + async with matrix_client() as client: + response = await client.room_send( + room_id=room_id, + message_type="m.reaction", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent reaction {emoji} to {event_id}") - return f"✓ Reacted with {emoji}" - elif isinstance(response, RoomSendError): - logger.error(f"Failed to send reaction: {response.message}") - return f"Error: {response.message}" - else: - return f"Error: Unexpected response type" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent reaction {emoji} to {event_id}") + return f"✓ Reacted with {emoji}" + elif isinstance(response, RoomSendError): + logger.error(f"Failed to send reaction: {response.message}") + return f"Error: {response.message}" + else: + return f"Error: Unexpected response type" except Exception as e: logger.error(f"Failed to send reaction: {e}") @@ -1337,43 +1197,27 @@ async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]: get_user_profile("@friend:matrix.org") """ try: - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + # 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 - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) + # Get avatar + avatar_response = await client.get_avatar(user_id) + avatar_url = None + if isinstance(avatar_response, ProfileGetAvatarResponse): + avatar_url = avatar_response.avatar_url - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] + profile = { + "user_id": user_id, + "display_name": display_name or user_id, + "avatar_url": avatar_url, + } - # Get display name - display_name_response = await client.get_displayname(user_id) - display_name = None - if isinstance(display_name_response, ProfileGetDisplayNameResponse): - display_name = display_name_response.displayname - - # Get avatar - avatar_response = await client.get_avatar(user_id) - avatar_url = None - if isinstance(avatar_response, ProfileGetAvatarResponse): - avatar_url = avatar_response.avatar_url - - await client.close() - - profile = { - "user_id": user_id, - "display_name": display_name or user_id, - "avatar_url": avatar_url, - } - - logger.info(f"Retrieved profile for {user_id}") - return profile + logger.info(f"Retrieved profile for {user_id}") + return profile except Exception as 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: return f"Error: status must be one of {valid_statuses}" - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + await client.set_presence(status, message if message else None) - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Set presence - await client.set_presence(status, message if message else None) - - await client.close() - - logger.info(f"Set presence to {status}") - return f"✓ Presence set to {status}" + (f": {message}" if message else "") + logger.info(f"Set presence to {status}") + return f"✓ Presence set to {status}" + (f": {message}" if message else "") except Exception as e: logger.error(f"Failed to set presence: {e}") @@ -1482,58 +1309,42 @@ async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: st if not mime_type: mime_type = 'application/octet-stream' - # Load credentials - creds = load_credentials() - - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Upload file logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)") - upload_response = await client.upload( - data_provider=lambda *args: file_data, - content_type=mime_type, - filename=filename, - filesize=len(file_data) - ) - if isinstance(upload_response, UploadError): - await client.close() - return f"Error uploading file: {upload_response.message}" + async with matrix_client() as client: + # Upload file + upload_response = await client.upload( + data_provider=lambda *args: file_data, + content_type=mime_type, + filename=filename, + filesize=len(file_data) + ) - # Send file message - content = { - "msgtype": "m.file", - "body": filename, - "url": upload_response.content_uri, - "info": { - "mimetype": mime_type, - "size": len(file_data), + if isinstance(upload_response, UploadError): + return f"Error uploading file: {upload_response.message}" + + # Send file message + content = { + "msgtype": "m.file", + "body": filename, + "url": upload_response.content_uri, + "info": { + "mimetype": mime_type, + "size": len(file_data), + } } - } - response = await client.room_send( - room_id=room_id, - message_type="m.room.message", - content=content - ) + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) - await client.close() - - if isinstance(response, RoomSendResponse): - logger.info(f"Sent file to {room_id}: {filename}") - return f"✓ File sent: {filename} (event ID: {response.event_id})" - else: - return f"Error: Failed to send file" + if isinstance(response, RoomSendResponse): + logger.info(f"Sent file to {room_id}: {filename}") + return f"✓ File sent: {filename} (event ID: {response.event_id})" + else: + return f"Error: Failed to send file" except Exception as e: logger.error(f"Failed to send file: {e}") @@ -1619,44 +1430,27 @@ async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]: get_room_members("!abc:matrix.org") """ try: - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + # Sync to get rooms + await client.sync(timeout=30000) - # 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), - ) + # Find the room + if room_id not in client.rooms: + return [{"error": f"Room not found: {room_id}"}] - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] + room = client.rooms[room_id] - # Sync to get rooms - await client.sync(timeout=30000) + # 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, + }) - # Find the room - if room_id not in client.rooms: - await client.close() - return [{"error": f"Room not found: {room_id}"}] - - room = client.rooms[room_id] - - # Get members - members = [] - for user_id, member in room.users.items(): - members.append({ - "user_id": user_id, - "display_name": member.display_name or user_id, - "power_level": member.power_level, - }) - - await client.close() - - logger.info(f"Retrieved {len(members)} members from {room_id}") - return members + logger.info(f"Retrieved {len(members)} members from {room_id}") + return members except Exception as e: logger.error(f"Failed to get room members: {e}") @@ -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") """ try: - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + await client.room_put_state( + room_id=room_id, + event_type="m.room.topic", + content={"topic": topic} + ) - # Create Matrix client - MATRIX_DATA_DIR.mkdir(exist_ok=True) - client = AsyncClient( - homeserver=creds['homeserver'], - user=creds['user_id'], - store_path=str(MATRIX_DATA_DIR), - ) - - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] - - # Set room topic - await client.room_put_state( - room_id=room_id, - event_type="m.room.topic", - content={"topic": topic} - ) - - await client.close() - - logger.info(f"Set topic for {room_id}: {topic[:50]}...") - return f"✓ Room topic updated" + logger.info(f"Set topic for {room_id}: {topic[:50]}...") + return f"✓ Room topic updated" except Exception as e: logger.error(f"Failed to set room topic: {e}") @@ -1730,45 +1507,28 @@ async def matrix_get_room_state(room_id: str) -> Dict[str, Any]: get_room_state("!abc:matrix.org") """ try: - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + # Sync to get rooms + await client.sync(timeout=30000) - # 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), - ) + # Find the room + if room_id not in client.rooms: + return {"error": f"Room not found: {room_id}"} - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] + room = client.rooms[room_id] - # Sync to get rooms - await client.sync(timeout=30000) + # 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'), + } - # Find the room - if room_id not in client.rooms: - await client.close() - return {"error": f"Room not found: {room_id}"} - - room = client.rooms[room_id] - - # Build state dict - state = { - "room_id": room_id, - "name": room.name or room.display_name or room_id, - "topic": room.topic, - "member_count": len(room.users), - "encrypted": room.encrypted, - "room_version": getattr(room, 'room_version', 'unknown'), - } - - await client.close() - - logger.info(f"Retrieved state for {room_id}") - return state + logger.info(f"Retrieved state for {room_id}") + return state except Exception as 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) """ try: - # Load credentials - creds = load_credentials() + async with matrix_client() as client: + # Get room messages + response = await client.room_messages( + room_id=room_id, + start="", + limit=100 # Search last 100 messages + ) - # 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), - ) + results = [] - # Restore session - client.access_token = creds['access_token'] - client.device_id = creds['device_id'] + 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, + }) - # Get room messages - response = await client.room_messages( - room_id=room_id, - start="", - limit=100 # Search last 100 messages - ) + if len(results) >= limit: + break - 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 + logger.info(f"Found {len(results)} messages matching '{query}'") + return results except Exception as e: logger.error(f"Failed to search messages: {e}") diff --git a/send_to_claude.py b/send_to_claude.py index ec903d7..5ebb83e 100755 --- a/send_to_claude.py +++ b/send_to_claude.py @@ -81,7 +81,7 @@ 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) + delay_seconds: Time to wait after CMD+R (default: 15) Returns: bool: True if successful diff --git a/vixy_status.py b/vixy_status.py index 3c090dd..5c979bd 100644 --- a/vixy_status.py +++ b/vixy_status.py @@ -9,11 +9,15 @@ Day 83: Added movement tracking to filter static false positives (posters!) """ import json +import logging import sqlite3 import requests from pathlib import Path from datetime import datetime, timedelta +# Setup logging +logger = logging.getLogger("vixy_status") + # Service endpoints ENVIRO_URL = "http://eye1.local:8767" OAK_URL = "http://head-vixy.local:8100" @@ -90,8 +94,8 @@ def get_enviro_status() -> str: humidity = data.get('humidity', 0) light = data.get('light', 0) return f"Basement: {temp_f:.1f}F, {humidity:.1f}% humidity, {light:.1f} lux" - except Exception: - pass + except Exception as e: + logger.warning(f"Enviro service unavailable: {e}") return "Basement: sensors unavailable" @@ -139,8 +143,8 @@ def get_presence_status() -> str: return f"Foxy: away (last seen {last_seen:.0f}s ago)" else: return "Foxy: away" - except Exception: - pass + except Exception as e: + logger.warning(f"OAK-D presence service unavailable: {e}") 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})" else: return f"{category}" - except Exception: - pass + except Exception as e: + logger.warning(f"Headmic sound service unavailable: {e}") return None # Return None to omit line if service unavailable @@ -178,60 +182,125 @@ def get_matrix_status() -> str: if STATE_FILE.exists(): with open(STATE_FILE, 'r') as f: state = json.load(f) - + messages = state.get('matrix_messages', []) unprocessed = [m for m in messages if not m.get('processed', False)] - + if unprocessed: return f"Matrix: {len(unprocessed)} new message(s)" else: return "Matrix: no new messages" - except Exception: - pass + except Exception as e: + logger.warning(f"Matrix status unavailable: {e}") return "Matrix: status unavailable" 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: if not EVENTS_DB.exists(): - return "Vision: no events database" - - conn = sqlite3.connect(str(EVENTS_DB)) - conn.row_factory = sqlite3.Row - + return "no events database" + # Get events from last 2 hours cutoff = (datetime.now() - timedelta(hours=2)).isoformat() - - cursor = conn.execute( - """SELECT camera_id, annotation FROM events - WHERE timestamp > ? - ORDER BY timestamp DESC""", - (cutoff,) - ) - - events = cursor.fetchall() - conn.close() - + + with sqlite3.connect(str(EVENTS_DB)) as conn: + conn.row_factory = sqlite3.Row + # Check if detections column exists + columns = [row[1] for row in conn.execute("PRAGMA table_info(events)").fetchall()] + has_detections = "detections" in columns + + if has_detections: + 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: - return "Vision: no recent motion" - - # Count by camera - by_camera = {} - unannotated = 0 + 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) + for event in events: cam = event['camera_id'] or 'unknown' - by_camera[cam] = by_camera.get(cam, 0) + 1 - if not event['annotation']: - unannotated += 1 - - total = len(events) - camera_breakdown = ", ".join(f"{cam}: {count}" for cam, count in by_camera.items()) - - return f"{camera_breakdown}" - + + # Parse detections JSON + dets = None + if event['detections']: + try: + dets = json.loads(event['detections']) + except (json.JSONDecodeError, TypeError): + pass + + 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: - return f"Vision: error ({e})" + return f"error ({e})" def format_status_for_wakeup() -> str: diff --git a/wakeup_mcp.py b/wakeup_mcp.py index 777e058..2257b17 100755 --- a/wakeup_mcp.py +++ b/wakeup_mcp.py @@ -221,7 +221,7 @@ def get_status() -> str: 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: + except Exception: status_lines.append(f"Last wake: {state.get('last_wake')}") else: 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)") else: 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')}") else: interval = state.get('interval_minutes', 60)