1854 lines
57 KiB
Python
Executable File
1854 lines
57 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Matrix Control MCP Server
|
|
|
|
Model Context Protocol server that allows Claude to interact with Matrix messages
|
|
queued by the Matrix integration.
|
|
|
|
Main Tools:
|
|
- get_matrix_messages() - Retrieve queued messages (text + images)
|
|
- get_matrix_image(event_id) - Retrieve a Matrix image by event ID
|
|
- matrix_mark_messages_processed(event_ids) - Mark messages as processed
|
|
- matrix_send_message(room_id, message) - Send text message with markdown formatting
|
|
- matrix_send_emote(room_id, action) - Send emote/action (like /me)
|
|
- matrix_send_image(room_id, file_path) - Send image file to a Matrix room
|
|
- matrix_send_voice(room_id, file_path) - Send voice message (audio file) to a Matrix room
|
|
- get_matrix_status() - Check Matrix integration status
|
|
- list_matrix_rooms() - List available Matrix rooms
|
|
- list_matrix_invites() - List room invites
|
|
- join_matrix_room(room_id) - Join a Matrix room
|
|
- matrix_reply_to_message(room_id, event_id, message) - Reply to a message
|
|
- matrix_react_to_message(room_id, event_id, emoji) - React with emoji
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import asyncio
|
|
import fcntl
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.server.fastmcp.utilities.types import Image as MCPImage
|
|
|
|
try:
|
|
from nio import (
|
|
AsyncClient,
|
|
RoomSendError,
|
|
RoomSendResponse,
|
|
UploadResponse,
|
|
UploadError,
|
|
JoinResponse,
|
|
JoinError,
|
|
RoomLeaveResponse,
|
|
RoomLeaveError,
|
|
RoomGetStateResponse,
|
|
RoomGetStateError,
|
|
RoomMessagesResponse,
|
|
RoomMessagesError,
|
|
ProfileGetResponse,
|
|
ProfileGetError,
|
|
ProfileGetDisplayNameResponse,
|
|
ProfileGetAvatarResponse,
|
|
DownloadResponse,
|
|
DownloadError,
|
|
RoomMemberEvent,
|
|
)
|
|
except ImportError:
|
|
raise ImportError(
|
|
"matrix-nio not installed. Install with: pip3 install matrix-nio\n"
|
|
"Note: Without [e2e] extra, only unencrypted rooms are supported"
|
|
)
|
|
|
|
# Configuration
|
|
STATE_FILE = Path.home() / ".claude-automation-state.json"
|
|
CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json"
|
|
MATRIX_DATA_DIR = Path.home() / ".matrix-data"
|
|
LOG_FILE = Path("/tmp/matrix-mcp.log")
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler(LOG_FILE),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize MCP server
|
|
mcp = FastMCP("Matrix Control")
|
|
|
|
|
|
def load_state() -> dict:
|
|
"""Load automation state from JSON file"""
|
|
if not STATE_FILE.exists():
|
|
return {
|
|
'interval_minutes': 60,
|
|
'paused': False,
|
|
'last_wake': None,
|
|
'next_wake_timestamp': None,
|
|
'matrix_messages': [],
|
|
'matrix_invites': [],
|
|
'matrix_last_wake': None,
|
|
'matrix_wake_requested': False,
|
|
}
|
|
|
|
try:
|
|
with open(STATE_FILE, 'r') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load state: {e}")
|
|
return {}
|
|
|
|
|
|
def save_state(state: dict):
|
|
"""Save automation state to JSON file atomically with file locking"""
|
|
try:
|
|
temp_path = STATE_FILE.with_suffix('.tmp')
|
|
with open(temp_path, 'w') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
json.dump(state, f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
temp_path.rename(STATE_FILE)
|
|
logger.info(f"Saved state atomically")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save state: {e}")
|
|
|
|
|
|
def load_credentials() -> dict:
|
|
"""Load Matrix credentials"""
|
|
if not CREDENTIALS_FILE.exists():
|
|
raise FileNotFoundError(
|
|
f"Credentials file not found: {CREDENTIALS_FILE}\n"
|
|
f"Run setup_matrix.sh to create credentials"
|
|
)
|
|
|
|
try:
|
|
with open(CREDENTIALS_FILE, 'r') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load credentials: {e}")
|
|
raise
|
|
|
|
|
|
@mcp.tool()
|
|
def get_matrix_messages(
|
|
limit: int = 10,
|
|
include_processed: bool = False
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Retrieve queued Matrix messages (metadata only).
|
|
|
|
**Messages are automatically marked as processed when retrieved!**
|
|
|
|
Returns metadata for both text messages and images. For images, use
|
|
get_matrix_image(event_id) to retrieve the actual image.
|
|
|
|
Args:
|
|
limit: Maximum number of messages to return (default: 10)
|
|
include_processed: Include already-processed messages (default: False)
|
|
|
|
Returns:
|
|
List of messages with structure:
|
|
|
|
Text messages:
|
|
- type: "text"
|
|
- room_id: Matrix room ID
|
|
- room_name: Human-readable room name
|
|
- sender: Message sender's Matrix ID
|
|
- message: Message text
|
|
- timestamp: ISO timestamp
|
|
- event_id: Matrix event ID
|
|
- processed: True (auto-marked when retrieved)
|
|
|
|
Image messages (metadata only):
|
|
- type: "image"
|
|
- room_id: Matrix room ID
|
|
- room_name: Human-readable room name
|
|
- sender: Message sender's Matrix ID
|
|
- filename: Image filename
|
|
- timestamp: ISO timestamp
|
|
- event_id: Matrix event ID (use with get_matrix_image())
|
|
- processed: True (auto-marked when retrieved)
|
|
|
|
Examples:
|
|
get_matrix_messages() - Get up to 10 unprocessed messages (auto-marks as processed)
|
|
get_matrix_messages(limit=50) - Get up to 50 unprocessed messages
|
|
get_matrix_messages(include_processed=True) - Include already-processed messages
|
|
|
|
# For images:
|
|
messages = get_matrix_messages()
|
|
for msg in messages:
|
|
if msg['type'] == 'image':
|
|
image = get_matrix_image(msg['event_id']) # Fetches actual image
|
|
"""
|
|
try:
|
|
state = load_state()
|
|
messages = state.get('matrix_messages', [])
|
|
logger.debug(f"Total messages in state: {len(messages)}")
|
|
|
|
# Filter by processed status
|
|
if not include_processed:
|
|
unprocessed_before = [msg for msg in messages if not msg.get('processed', False)]
|
|
logger.debug(f"Unprocessed messages before filtering: {len(unprocessed_before)}")
|
|
messages = unprocessed_before
|
|
|
|
# Limit results
|
|
messages = messages[:limit]
|
|
logger.debug(f"Messages after limit: {len(messages)}")
|
|
|
|
# Automatically mark these messages as processed (they're being retrieved)
|
|
event_ids_to_mark = [msg['event_id'] for msg in messages]
|
|
if event_ids_to_mark:
|
|
logger.debug(f"About to mark {len(event_ids_to_mark)} messages as processed")
|
|
marked_count = _mark_messages_processed_internal(event_ids_to_mark)
|
|
logger.info(f"Auto-marked {marked_count} messages as processed")
|
|
else:
|
|
logger.debug("No messages to mark as processed")
|
|
|
|
# Format messages for return (metadata only)
|
|
formatted_messages = []
|
|
for msg in messages:
|
|
if msg['type'] == 'image':
|
|
# Return metadata only - use get_matrix_image(event_id) to fetch actual image
|
|
formatted_msg = {
|
|
'type': 'image',
|
|
'room_id': msg['room_id'],
|
|
'room_name': msg['room_name'],
|
|
'sender': msg['sender'],
|
|
'filename': msg['filename'],
|
|
'timestamp': msg['timestamp'],
|
|
'event_id': msg['event_id'],
|
|
'processed': True, # Now marked as processed
|
|
}
|
|
elif msg['type'] == 'audio':
|
|
# Return metadata only - use get_matrix_audio(event_id) to fetch actual audio
|
|
formatted_msg = {
|
|
'type': 'audio',
|
|
'room_id': msg['room_id'],
|
|
'room_name': msg['room_name'],
|
|
'sender': msg['sender'],
|
|
'filename': msg['filename'],
|
|
'timestamp': msg['timestamp'],
|
|
'event_id': msg['event_id'],
|
|
'processed': True, # Now marked as processed
|
|
}
|
|
else:
|
|
formatted_msg = msg.copy() # Make a copy to avoid modifying original
|
|
formatted_msg['processed'] = True # Now marked as processed
|
|
|
|
formatted_messages.append(formatted_msg)
|
|
|
|
logger.info(f"Returned {len(formatted_messages)} Matrix messages")
|
|
return formatted_messages
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Matrix messages: {e}")
|
|
return []
|
|
|
|
|
|
@mcp.tool()
|
|
def get_matrix_image(event_id: str):
|
|
"""
|
|
Retrieve a Matrix image by event ID.
|
|
|
|
Use this after get_matrix_messages() returns an image message to
|
|
fetch the actual image for inline display.
|
|
|
|
Args:
|
|
event_id: Matrix event ID of the image message
|
|
|
|
Returns:
|
|
MCPImage object for inline display in Claude Desktop
|
|
|
|
Examples:
|
|
get_matrix_image("$abc123...")
|
|
"""
|
|
try:
|
|
import base64
|
|
|
|
state = load_state()
|
|
messages = state.get('matrix_messages', [])
|
|
|
|
# Find the image message
|
|
for msg in messages:
|
|
if msg['event_id'] == event_id and msg['type'] == 'image':
|
|
# Decode base64 to raw bytes
|
|
image_bytes = base64.b64decode(msg['image_data'])
|
|
logger.info(f"Returning image: {msg['filename']} ({len(image_bytes)} bytes)")
|
|
# Return MCPImage object like image-watch-mcp does
|
|
return MCPImage(data=image_bytes, format="jpeg")
|
|
|
|
logger.warning(f"Image not found for event_id: {event_id}")
|
|
raise ValueError(f"No image found with event_id: {event_id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get image: {e}")
|
|
raise
|
|
|
|
|
|
@mcp.tool()
|
|
def get_matrix_audio(event_id: str, save_path: str = None) -> str:
|
|
"""
|
|
Retrieve a Matrix audio/voice message by event ID.
|
|
|
|
Use this after get_matrix_messages() returns an audio message.
|
|
Saves the audio to a file and returns the path.
|
|
|
|
Args:
|
|
event_id: Matrix event ID of the audio message
|
|
save_path: Optional path to save audio (defaults to temp file)
|
|
|
|
Returns:
|
|
Path to the saved audio file
|
|
|
|
Examples:
|
|
audio_path = get_matrix_audio("$abc123...")
|
|
transcription = ear_transcribe(audio_path)
|
|
"""
|
|
try:
|
|
import base64
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
state = load_state()
|
|
messages = state.get('matrix_messages', [])
|
|
|
|
# Find the audio message
|
|
for msg in messages:
|
|
if msg['event_id'] == event_id and msg['type'] == 'audio':
|
|
# Decode base64 to raw bytes
|
|
audio_bytes = base64.b64decode(msg['audio_data'])
|
|
|
|
# Determine save path
|
|
if save_path:
|
|
audio_path = Path(save_path).expanduser()
|
|
else:
|
|
# Use temp file with original extension
|
|
filename = msg.get('filename', 'voice.ogg')
|
|
ext = Path(filename).suffix or '.ogg'
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
audio_path = temp_dir / f"matrix_audio_{event_id[:8]}{ext}"
|
|
|
|
# Save audio file
|
|
with open(audio_path, 'wb') as f:
|
|
f.write(audio_bytes)
|
|
|
|
logger.info(f"Saved audio: {audio_path} ({len(audio_bytes)} bytes)")
|
|
return str(audio_path)
|
|
|
|
logger.warning(f"Audio not found for event_id: {event_id}")
|
|
raise ValueError(f"No audio found with event_id: {event_id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get audio: {e}")
|
|
raise
|
|
|
|
|
|
def _mark_messages_processed_internal(event_ids: List[str]) -> int:
|
|
"""
|
|
Internal helper to mark messages as processed.
|
|
|
|
Returns:
|
|
Number of messages marked
|
|
"""
|
|
state = load_state()
|
|
messages = state.get('matrix_messages', [])
|
|
|
|
marked_count = 0
|
|
for msg in messages:
|
|
if msg['event_id'] in event_ids:
|
|
msg['processed'] = True
|
|
marked_count += 1
|
|
|
|
state['matrix_messages'] = messages
|
|
save_state(state)
|
|
|
|
# Clear wake request flag if all messages processed
|
|
unprocessed = [msg for msg in messages if not msg.get('processed', False)]
|
|
if not unprocessed:
|
|
state['matrix_wake_requested'] = False
|
|
save_state(state)
|
|
|
|
return marked_count
|
|
|
|
|
|
@mcp.tool()
|
|
def matrix_mark_messages_processed(event_ids: List[str]) -> str:
|
|
"""
|
|
Mark Matrix messages as processed.
|
|
|
|
After Claude has responded to messages, mark them as processed so they
|
|
won't be returned in future get_matrix_messages() calls.
|
|
|
|
Args:
|
|
event_ids: List of Matrix event IDs to mark as processed
|
|
|
|
Returns:
|
|
Confirmation message
|
|
|
|
Examples:
|
|
matrix_mark_messages_processed(["$event1", "$event2"])
|
|
"""
|
|
try:
|
|
marked_count = _mark_messages_processed_internal(event_ids)
|
|
logger.info(f"Marked {marked_count} messages as processed")
|
|
return f"✓ Marked {marked_count} messages as processed"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to mark messages: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_send_image(room_id: str, file_path: str) -> str:
|
|
"""
|
|
Send an image to a Matrix room from a local file.
|
|
|
|
Reads the image from the file system, uploads it to the Matrix homeserver,
|
|
and sends it to the specified room. Works with files from DreamTail,
|
|
image-watch, or any other local image file.
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
file_path: Path to the image file on disk
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
matrix_send_image("!abc123:matrix.org", "/path/to/image.jpg")
|
|
matrix_send_image("!abc123:matrix.org", "~/Downloads/photo.png")
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
# Resolve path
|
|
path = Path(file_path).expanduser().resolve()
|
|
if not path.exists():
|
|
return f"Error: File not found: {file_path}"
|
|
|
|
if not path.is_file():
|
|
return f"Error: Not a file: {file_path}"
|
|
|
|
# Read image data
|
|
try:
|
|
image_data = path.read_bytes()
|
|
except Exception as e:
|
|
return f"Error reading file: {str(e)}"
|
|
|
|
# Get image dimensions and format
|
|
try:
|
|
img = Image.open(BytesIO(image_data))
|
|
width, height = img.size
|
|
img_format = img.format.lower() if img.format else 'jpeg'
|
|
mime_type = f'image/{img_format}'
|
|
except Exception as e:
|
|
logger.warning(f"Could not extract image info: {e}")
|
|
width, height = None, None
|
|
mime_type = 'image/jpeg'
|
|
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Upload image to Matrix homeserver using aiohttp directly
|
|
# (nio's upload has timeout issues)
|
|
filename = path.name
|
|
logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)")
|
|
|
|
import aiohttp
|
|
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
|
|
headers = {
|
|
"Authorization": f"Bearer {creds['access_token']}",
|
|
"Content-Type": mime_type,
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(upload_url, data=image_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
content_uri = result.get("content_uri")
|
|
logger.info(f"Upload successful: {content_uri}")
|
|
else:
|
|
error_text = await resp.text()
|
|
await client.close()
|
|
logger.error(f"Upload failed: {resp.status} - {error_text}")
|
|
return f"Error uploading image: {resp.status} - {error_text}"
|
|
|
|
# Send image message
|
|
content = {
|
|
"msgtype": "m.image",
|
|
"body": filename,
|
|
"url": content_uri,
|
|
"info": {
|
|
"mimetype": mime_type,
|
|
"size": len(image_data),
|
|
}
|
|
}
|
|
|
|
if width and height:
|
|
content["info"]["w"] = width
|
|
content["info"]["h"] = height
|
|
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent image to {room_id}: {filename}")
|
|
return f"✓ Image sent to room: {filename} (event ID: {response.event_id})"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send image: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send image from file: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_send_voice(room_id: str, file_path: str) -> str:
|
|
"""
|
|
Send a voice message to a Matrix room from a local audio file.
|
|
|
|
Reads the audio file (WAV, OGG, MP3, etc.) from the file system, uploads it
|
|
to the Matrix homeserver, and sends it as a voice message. Works with files
|
|
from voice-mcp or any other local audio file.
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
file_path: Path to the audio file on disk (e.g., "~/voice_audio/abc123.wav")
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
matrix_send_voice("!abc123:matrix.org", "~/voice_audio/job123.wav")
|
|
matrix_send_voice("!abc123:matrix.org", "/tmp/recording.ogg")
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
import wave
|
|
|
|
# Resolve path
|
|
path = Path(file_path).expanduser().resolve()
|
|
if not path.exists():
|
|
return f"Error: File not found: {file_path}"
|
|
|
|
if not path.is_file():
|
|
return f"Error: Not a file: {file_path}"
|
|
|
|
# Read audio data
|
|
try:
|
|
audio_data = path.read_bytes()
|
|
except Exception as e:
|
|
return f"Error reading file: {str(e)}"
|
|
|
|
# Detect audio format and get metadata
|
|
file_ext = path.suffix.lower()
|
|
|
|
# Map file extension to MIME type
|
|
mime_type_map = {
|
|
'.wav': 'audio/wav',
|
|
'.ogg': 'audio/ogg',
|
|
'.opus': 'audio/opus',
|
|
'.mp3': 'audio/mpeg',
|
|
'.m4a': 'audio/mp4',
|
|
'.aac': 'audio/aac',
|
|
'.flac': 'audio/flac',
|
|
}
|
|
mime_type = mime_type_map.get(file_ext, 'audio/wav')
|
|
|
|
# Try to get duration for WAV files
|
|
duration_ms = None
|
|
if file_ext == '.wav':
|
|
try:
|
|
with wave.open(str(path), 'rb') as wav_file:
|
|
frames = wav_file.getnframes()
|
|
rate = wav_file.getframerate()
|
|
duration_ms = int((frames / rate) * 1000)
|
|
except Exception as e:
|
|
logger.warning(f"Could not extract WAV duration: {e}")
|
|
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Upload audio to Matrix homeserver using aiohttp directly
|
|
# (nio's upload has timeout issues)
|
|
filename = path.name
|
|
logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)")
|
|
|
|
import aiohttp
|
|
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
|
|
headers = {
|
|
"Authorization": f"Bearer {creds['access_token']}",
|
|
"Content-Type": mime_type,
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(upload_url, data=audio_data, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
content_uri = result.get("content_uri")
|
|
logger.info(f"Upload successful: {content_uri}")
|
|
else:
|
|
error_text = await resp.text()
|
|
await client.close()
|
|
logger.error(f"Upload failed: {resp.status} - {error_text}")
|
|
return f"Error uploading audio: {resp.status} - {error_text}"
|
|
|
|
# Send voice message
|
|
content = {
|
|
"msgtype": "m.audio",
|
|
"body": filename,
|
|
"url": content_uri,
|
|
"info": {
|
|
"mimetype": mime_type,
|
|
"size": len(audio_data),
|
|
}
|
|
}
|
|
|
|
# Add duration if we extracted it
|
|
if duration_ms:
|
|
content["info"]["duration"] = duration_ms
|
|
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent voice message to {room_id}: {filename}")
|
|
return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send voice message: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send voice message from file: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
def _convert_markdown_to_html(text: str) -> str:
|
|
"""
|
|
Convert basic markdown formatting to Matrix HTML.
|
|
|
|
Supports:
|
|
- **bold** → <strong>bold</strong>
|
|
- *italic* → <em>italic</em>
|
|
- ***bold italic*** → <strong><em>bold italic</em></strong>
|
|
"""
|
|
import re
|
|
|
|
# Convert ***bold italic*** first (three asterisks)
|
|
text = re.sub(r'\*\*\*(.+?)\*\*\*', r'<strong><em>\1</em></strong>', text)
|
|
|
|
# Convert **bold** (two asterisks)
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
|
|
|
# Convert *italic* (single asterisk)
|
|
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
|
|
|
return text
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_send_message(room_id: str, message: str) -> str:
|
|
"""
|
|
Send a message to a Matrix room.
|
|
|
|
Supports markdown-style formatting (*italic*, **bold**).
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
message: Text message to send (supports *italic*, **bold**)
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
matrix_send_message("!abc123:matrix.org", "Hello from Claude!")
|
|
matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Convert markdown to HTML
|
|
html_body = _convert_markdown_to_html(message)
|
|
|
|
# Build message content
|
|
content = {
|
|
"msgtype": "m.text",
|
|
"body": message, # Plain text fallback
|
|
}
|
|
|
|
# Only add formatted_body if there's actual HTML formatting
|
|
if html_body != message:
|
|
content["format"] = "org.matrix.custom.html"
|
|
content["formatted_body"] = html_body
|
|
|
|
# Send message
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent message to {room_id}: {message[:50]}...")
|
|
return f"✓ Message sent to room (event ID: {response.event_id})"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send message: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send message: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_send_emote(room_id: str, action: str) -> str:
|
|
"""
|
|
Send an emote/action to a Matrix room (like /me in IRC).
|
|
|
|
Displays as "* username action" instead of "username: message".
|
|
Supports markdown-style formatting (*italic*, **bold**).
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
action: Action text (supports *italic*, **bold**)
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
matrix_send_emote("!abc123:matrix.org", "waves hello")
|
|
matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Convert markdown to HTML
|
|
html_body = _convert_markdown_to_html(action)
|
|
|
|
# Build message content
|
|
content = {
|
|
"msgtype": "m.emote",
|
|
"body": action, # Plain text fallback
|
|
}
|
|
|
|
# Only add formatted_body if there's actual HTML formatting
|
|
if html_body != action:
|
|
content["format"] = "org.matrix.custom.html"
|
|
content["formatted_body"] = html_body
|
|
|
|
# Send message
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent emote to {room_id}: {action[:50]}...")
|
|
return f"✓ Emote sent to room (event ID: {response.event_id})"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send emote: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send emote: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
def get_matrix_status() -> str:
|
|
"""
|
|
Get Matrix integration status.
|
|
|
|
Shows:
|
|
- Connection status
|
|
- Number of queued messages
|
|
- Last Matrix wake time
|
|
- Rate limit status
|
|
|
|
Returns:
|
|
Formatted status report
|
|
"""
|
|
try:
|
|
state = load_state()
|
|
|
|
status_lines = ["Matrix Integration Status:"]
|
|
status_lines.append("=" * 40)
|
|
|
|
# Credentials check
|
|
if CREDENTIALS_FILE.exists():
|
|
try:
|
|
creds = load_credentials()
|
|
status_lines.append(f"✓ Connected as: {creds['user_id']}")
|
|
status_lines.append(f" Homeserver: {creds['homeserver']}")
|
|
|
|
# Room whitelist
|
|
whitelist = creds.get('room_whitelist', [])
|
|
if whitelist:
|
|
status_lines.append(f" Monitoring: {len(whitelist)} rooms (whitelist)")
|
|
else:
|
|
status_lines.append(f" Monitoring: All rooms")
|
|
|
|
except Exception as e:
|
|
status_lines.append(f"⚠️ Credentials error: {e}")
|
|
else:
|
|
status_lines.append("✗ Not configured (run setup_matrix.sh)")
|
|
|
|
# Message queue
|
|
messages = state.get('matrix_messages', [])
|
|
unprocessed_msgs = [msg for msg in messages if not msg.get('processed', False)]
|
|
status_lines.append(f"Messages queued: {len(unprocessed_msgs)} unprocessed, {len(messages)} total")
|
|
|
|
# Invite queue
|
|
invites = state.get('matrix_invites', [])
|
|
unprocessed_invs = [inv for inv in invites if not inv.get('processed', False)]
|
|
status_lines.append(f"Invites queued: {len(unprocessed_invs)} unprocessed, {len(invites)} total")
|
|
|
|
# Last wake
|
|
last_wake_str = state.get('matrix_last_wake')
|
|
if last_wake_str:
|
|
try:
|
|
last_wake = datetime.fromisoformat(last_wake_str)
|
|
time_ago = datetime.now() - last_wake
|
|
status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
|
|
except:
|
|
status_lines.append(f"Last Matrix wake: {last_wake_str}")
|
|
else:
|
|
status_lines.append("Last Matrix wake: Never")
|
|
|
|
# Rate limit status
|
|
if last_wake_str:
|
|
try:
|
|
last_wake = datetime.fromisoformat(last_wake_str)
|
|
elapsed = (datetime.now() - last_wake).total_seconds()
|
|
if elapsed >= 120:
|
|
status_lines.append("Rate limit: OK (can wake)")
|
|
else:
|
|
remaining = 120 - elapsed
|
|
status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)")
|
|
except:
|
|
status_lines.append("Rate limit: Unknown")
|
|
else:
|
|
status_lines.append("Rate limit: OK (can wake)")
|
|
|
|
# Wake request flag
|
|
if state.get('matrix_wake_requested'):
|
|
status_lines.append("⚡ Wake requested: Yes (daemon will wake Claude)")
|
|
else:
|
|
status_lines.append("Wake requested: No")
|
|
|
|
return "\n".join(status_lines)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get status: {e}")
|
|
return f"Error getting status: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
def list_matrix_invites(include_processed: bool = False) -> List[Dict[str, Any]]:
|
|
"""
|
|
List pending Matrix room invites.
|
|
|
|
**Invites are automatically marked as processed when retrieved!**
|
|
|
|
Shows all rooms the bot has been invited to but hasn't joined yet.
|
|
|
|
Args:
|
|
include_processed: Include already-processed invites (default: False)
|
|
|
|
Returns:
|
|
List of invites with structure:
|
|
- room_id: Matrix room ID
|
|
- room_name: Human-readable room name
|
|
- inviter: Who invited you
|
|
- timestamp: ISO timestamp
|
|
- processed: True (auto-marked when retrieved)
|
|
|
|
Examples:
|
|
list_matrix_invites() - Get unprocessed invites (auto-marks as processed)
|
|
list_matrix_invites(include_processed=True) - Include processed invites
|
|
"""
|
|
try:
|
|
state = load_state()
|
|
invites = state.get('matrix_invites', [])
|
|
|
|
# Filter by processed status
|
|
if not include_processed:
|
|
invites = [inv for inv in invites if not inv.get('processed', False)]
|
|
|
|
# Automatically mark these invites as processed (they're being retrieved)
|
|
room_ids_to_mark = [inv['room_id'] for inv in invites]
|
|
if room_ids_to_mark:
|
|
marked_count = _mark_invites_processed_internal(room_ids_to_mark)
|
|
logger.info(f"Auto-marked {marked_count} invites as processed")
|
|
|
|
# Update the returned data to reflect processed status
|
|
for inv in invites:
|
|
inv['processed'] = True
|
|
|
|
logger.info(f"Returned {len(invites)} Matrix invites")
|
|
return invites
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list invites: {e}")
|
|
return []
|
|
|
|
|
|
def _mark_invites_processed_internal(room_ids: List[str]) -> int:
|
|
"""
|
|
Internal helper to mark invites as processed.
|
|
|
|
Returns:
|
|
Number of invites marked
|
|
"""
|
|
state = load_state()
|
|
invites = state.get('matrix_invites', [])
|
|
|
|
marked_count = 0
|
|
for invite in invites:
|
|
if invite['room_id'] in room_ids:
|
|
invite['processed'] = True
|
|
marked_count += 1
|
|
|
|
state['matrix_invites'] = invites
|
|
save_state(state)
|
|
|
|
return marked_count
|
|
|
|
|
|
@mcp.tool()
|
|
def matrix_mark_invites_processed(room_ids: List[str]) -> str:
|
|
"""
|
|
Mark Matrix invites as processed.
|
|
|
|
After joining or declining invites, mark them as processed so they
|
|
won't appear in future list_matrix_invites() calls.
|
|
|
|
Args:
|
|
room_ids: List of Matrix room IDs to mark as processed
|
|
|
|
Returns:
|
|
Confirmation message
|
|
|
|
Examples:
|
|
mark_invites_processed(["!abc123:matrix.org", "!xyz789:matrix.org"])
|
|
"""
|
|
try:
|
|
marked_count = _mark_invites_processed_internal(room_ids)
|
|
logger.info(f"Marked {marked_count} invites as processed")
|
|
return f"✓ Marked {marked_count} invites as processed"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to mark invites: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def join_matrix_room(room_id: str) -> str:
|
|
"""
|
|
Join a Matrix room (accept invite or join public room).
|
|
|
|
Use this to accept pending invites or join public rooms.
|
|
Automatically marks the invite as processed.
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
join_matrix_room("!abc123:matrix.org")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Join room
|
|
logger.info(f"Attempting to join room: {room_id}")
|
|
response = await client.join(room_id)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, JoinResponse):
|
|
logger.info(f"Successfully joined room: {room_id}")
|
|
|
|
# Mark invite as processed
|
|
_mark_invites_processed_internal([room_id])
|
|
|
|
return f"✓ Joined room: {room_id}"
|
|
elif isinstance(response, JoinError):
|
|
logger.error(f"Failed to join room: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to join room: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def leave_matrix_room(room_id: str) -> str:
|
|
"""
|
|
Leave a Matrix room.
|
|
|
|
Use this to leave rooms you no longer want to monitor.
|
|
|
|
Args:
|
|
room_id: Matrix room ID (e.g., "!abc123:matrix.org")
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
leave_matrix_room("!abc123:matrix.org")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Leave room
|
|
logger.info(f"Attempting to leave room: {room_id}")
|
|
response = await client.room_leave(room_id)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomLeaveResponse):
|
|
logger.info(f"Successfully left room: {room_id}")
|
|
return f"✓ Left room: {room_id}"
|
|
elif isinstance(response, RoomLeaveError):
|
|
logger.error(f"Failed to leave room: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to leave room: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_matrix_rooms() -> str:
|
|
"""
|
|
List Matrix rooms the bot is in.
|
|
|
|
Shows room IDs, names, and member counts for all joined rooms.
|
|
|
|
Returns:
|
|
Formatted list of rooms
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Sync to get rooms
|
|
await client.sync(timeout=30000)
|
|
|
|
# Get room whitelist
|
|
whitelist = set(creds.get('room_whitelist', []))
|
|
|
|
# Format room list
|
|
rooms_lines = ["Matrix Rooms:"]
|
|
rooms_lines.append("=" * 60)
|
|
|
|
for room_id, room in client.rooms.items():
|
|
# Check if room is whitelisted
|
|
monitored = "✓" if (not whitelist or room_id in whitelist) else "✗"
|
|
|
|
rooms_lines.append(f"{monitored} {room.display_name or room.room_id}")
|
|
rooms_lines.append(f" ID: {room_id}")
|
|
rooms_lines.append(f" Members: {len(room.users)}")
|
|
rooms_lines.append("")
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Listed {len(client.rooms)} Matrix rooms")
|
|
return "\n".join(rooms_lines)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list rooms: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_reply_to_message(room_id: str, event_id: str, message: str) -> str:
|
|
"""
|
|
Reply to a specific Matrix message (threaded reply).
|
|
|
|
Creates a threaded reply that shows context of the original message.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
event_id: Event ID of message to reply to
|
|
message: Your reply text
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Send threaded reply
|
|
content = {
|
|
"msgtype": "m.text",
|
|
"body": message,
|
|
"m.relates_to": {
|
|
"m.in_reply_to": {
|
|
"event_id": event_id
|
|
}
|
|
}
|
|
}
|
|
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent reply to {event_id} in {room_id}")
|
|
return f"✓ Reply sent (event ID: {response.event_id})"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send reply: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reply: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> str:
|
|
"""
|
|
Add an emoji reaction to a Matrix message.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
event_id: Event ID of message to react to
|
|
emoji: Emoji to react with (e.g., "👍", "❤️", "😂")
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
react_to_message("!abc:matrix.org", "$event123", "👍")
|
|
react_to_message("!abc:matrix.org", "$event123", "❤️")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Send reaction
|
|
content = {
|
|
"m.relates_to": {
|
|
"rel_type": "m.annotation",
|
|
"event_id": event_id,
|
|
"key": emoji
|
|
}
|
|
}
|
|
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.reaction",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent reaction {emoji} to {event_id}")
|
|
return f"✓ Reacted with {emoji}"
|
|
elif isinstance(response, RoomSendError):
|
|
logger.error(f"Failed to send reaction: {response.message}")
|
|
return f"Error: {response.message}"
|
|
else:
|
|
return f"Error: Unexpected response type"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reaction: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]:
|
|
"""
|
|
Get a Matrix user's profile information.
|
|
|
|
Returns display name, avatar URL, and other profile data.
|
|
|
|
Args:
|
|
user_id: Matrix user ID (e.g., "@user:matrix.org")
|
|
|
|
Returns:
|
|
Dict with profile info: display_name, avatar_url
|
|
|
|
Examples:
|
|
get_user_profile("@friend:matrix.org")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Get display name
|
|
display_name_response = await client.get_displayname(user_id)
|
|
display_name = None
|
|
if isinstance(display_name_response, ProfileGetDisplayNameResponse):
|
|
display_name = display_name_response.displayname
|
|
|
|
# Get avatar
|
|
avatar_response = await client.get_avatar(user_id)
|
|
avatar_url = None
|
|
if isinstance(avatar_response, ProfileGetAvatarResponse):
|
|
avatar_url = avatar_response.avatar_url
|
|
|
|
await client.close()
|
|
|
|
profile = {
|
|
"user_id": user_id,
|
|
"display_name": display_name or user_id,
|
|
"avatar_url": avatar_url,
|
|
}
|
|
|
|
logger.info(f"Retrieved profile for {user_id}")
|
|
return profile
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get user profile: {e}")
|
|
return {"error": str(e)}
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_set_presence(status: str, message: str = "") -> str:
|
|
"""
|
|
Set your Matrix presence status.
|
|
|
|
Args:
|
|
status: "online", "offline", or "unavailable"
|
|
message: Optional status message
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
set_presence("online", "Working on tasks")
|
|
set_presence("away", "Out for lunch")
|
|
set_presence("offline")
|
|
"""
|
|
try:
|
|
# Validate status
|
|
valid_statuses = ["online", "offline", "unavailable"]
|
|
if status not in valid_statuses:
|
|
return f"Error: status must be one of {valid_statuses}"
|
|
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Set presence
|
|
await client.set_presence(status, message if message else None)
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Set presence to {status}")
|
|
return f"✓ Presence set to {status}" + (f": {message}" if message else "")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to set presence: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: str = None) -> str:
|
|
"""
|
|
Send a file to a Matrix room.
|
|
|
|
Accepts either file path (string) or file data (bytes). Auto-detects type.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
file: File path string OR file bytes/data
|
|
filename: Filename to display
|
|
mime_type: Optional MIME type (auto-detected if None)
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
send_matrix_file("!abc:matrix.org", "/path/to/document.pdf", "report.pdf")
|
|
send_matrix_file("!abc:matrix.org", file_bytes, "data.json", "application/json")
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
import mimetypes
|
|
|
|
# Auto-detect: file path vs file data
|
|
if isinstance(file, str):
|
|
# File path
|
|
file_path = Path(file).expanduser()
|
|
if not file_path.exists():
|
|
return f"Error: File not found: {file_path}"
|
|
|
|
with open(file_path, 'rb') as f:
|
|
file_data = f.read()
|
|
|
|
# Auto-detect MIME type from path
|
|
if not mime_type:
|
|
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
if not mime_type:
|
|
mime_type = 'application/octet-stream'
|
|
else:
|
|
# File data (bytes or object with .data)
|
|
if isinstance(file, bytes):
|
|
file_data = file
|
|
elif hasattr(file, 'data'):
|
|
file_data = file.data
|
|
else:
|
|
return "Error: file must be path string or bytes/data object"
|
|
|
|
if not mime_type:
|
|
mime_type = 'application/octet-stream'
|
|
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Upload file
|
|
logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)")
|
|
upload_response = await client.upload(
|
|
data_provider=lambda *args: file_data,
|
|
content_type=mime_type,
|
|
filename=filename,
|
|
filesize=len(file_data)
|
|
)
|
|
|
|
if isinstance(upload_response, UploadError):
|
|
await client.close()
|
|
return f"Error uploading file: {upload_response.message}"
|
|
|
|
# Send file message
|
|
content = {
|
|
"msgtype": "m.file",
|
|
"body": filename,
|
|
"url": upload_response.content_uri,
|
|
"info": {
|
|
"mimetype": mime_type,
|
|
"size": len(file_data),
|
|
}
|
|
}
|
|
|
|
response = await client.room_send(
|
|
room_id=room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
await client.close()
|
|
|
|
if isinstance(response, RoomSendResponse):
|
|
logger.info(f"Sent file to {room_id}: {filename}")
|
|
return f"✓ File sent: {filename} (event ID: {response.event_id})"
|
|
else:
|
|
return f"Error: Failed to send file"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send file: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def download_matrix_attachment(event_id: str, save_path: str = None) -> Any:
|
|
"""
|
|
Download a file attachment from a Matrix message.
|
|
|
|
If save_path provided, saves to disk and returns path.
|
|
If no save_path, returns raw file bytes.
|
|
|
|
Args:
|
|
event_id: Event ID of file message
|
|
save_path: Optional path to save file (if None, returns bytes)
|
|
|
|
Returns:
|
|
File path (if saved) or file bytes (if not saved)
|
|
|
|
Examples:
|
|
download_matrix_attachment("$event123", "~/Downloads/file.pdf")
|
|
download_matrix_attachment("$event123") # Returns bytes
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
|
|
# Find file in state
|
|
state = load_state()
|
|
messages = state.get('matrix_messages', [])
|
|
|
|
file_msg = None
|
|
for msg in messages:
|
|
if msg.get('event_id') == event_id and msg.get('type') in ['image', 'file']:
|
|
file_msg = msg
|
|
break
|
|
|
|
if not file_msg:
|
|
return "Error: File message not found with that event ID"
|
|
|
|
# For images, decode from base64
|
|
if file_msg['type'] == 'image':
|
|
import base64
|
|
file_data = base64.b64decode(file_msg['image_data'])
|
|
else:
|
|
# For files, need to download from Matrix (would need mxc URL in state)
|
|
return "Error: File download not yet implemented for non-image files"
|
|
|
|
# Save or return
|
|
if save_path:
|
|
save_path = Path(save_path).expanduser()
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(save_path, 'wb') as f:
|
|
f.write(file_data)
|
|
|
|
logger.info(f"Saved file to {save_path}")
|
|
return f"✓ Saved to {save_path}"
|
|
else:
|
|
logger.info(f"Returning file bytes ({len(file_data)} bytes)")
|
|
return file_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to download attachment: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get list of members in a Matrix room.
|
|
|
|
Returns member list with display names and power levels.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
|
|
Returns:
|
|
List of members with user_id, display_name, power_level
|
|
|
|
Examples:
|
|
get_room_members("!abc:matrix.org")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Sync to get rooms
|
|
await client.sync(timeout=30000)
|
|
|
|
# Find the room
|
|
if room_id not in client.rooms:
|
|
await client.close()
|
|
return [{"error": f"Room not found: {room_id}"}]
|
|
|
|
room = client.rooms[room_id]
|
|
|
|
# Get members
|
|
members = []
|
|
for user_id, member in room.users.items():
|
|
members.append({
|
|
"user_id": user_id,
|
|
"display_name": member.display_name or user_id,
|
|
"power_level": member.power_level,
|
|
})
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Retrieved {len(members)} members from {room_id}")
|
|
return members
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get room members: {e}")
|
|
return [{"error": str(e)}]
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_set_room_topic(room_id: str, topic: str) -> str:
|
|
"""
|
|
Set the topic/description of a Matrix room.
|
|
|
|
Requires sufficient power level in the room.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
topic: New room topic/description
|
|
|
|
Returns:
|
|
Confirmation message or error
|
|
|
|
Examples:
|
|
set_room_topic("!abc:matrix.org", "Discussion about AI projects")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Set room topic
|
|
await client.room_put_state(
|
|
room_id=room_id,
|
|
event_type="m.room.topic",
|
|
content={"topic": topic}
|
|
)
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Set topic for {room_id}: {topic[:50]}...")
|
|
return f"✓ Room topic updated"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to set room topic: {e}")
|
|
return f"Error: {str(e)}"
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_get_room_state(room_id: str) -> Dict[str, Any]:
|
|
"""
|
|
Get comprehensive state information about a Matrix room.
|
|
|
|
Returns room name, topic, member count, encryption status, etc.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
|
|
Returns:
|
|
Dict with room state information
|
|
|
|
Examples:
|
|
get_room_state("!abc:matrix.org")
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Sync to get rooms
|
|
await client.sync(timeout=30000)
|
|
|
|
# Find the room
|
|
if room_id not in client.rooms:
|
|
await client.close()
|
|
return {"error": f"Room not found: {room_id}"}
|
|
|
|
room = client.rooms[room_id]
|
|
|
|
# Build state dict
|
|
state = {
|
|
"room_id": room_id,
|
|
"name": room.name or room.display_name or room_id,
|
|
"topic": room.topic,
|
|
"member_count": len(room.users),
|
|
"encrypted": room.encrypted,
|
|
"room_version": getattr(room, 'room_version', 'unknown'),
|
|
}
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Retrieved state for {room_id}")
|
|
return state
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get room state: {e}")
|
|
return {"error": str(e)}
|
|
|
|
|
|
@mcp.tool()
|
|
async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search for messages in a Matrix room.
|
|
|
|
Searches recent chat history for text matching the query.
|
|
|
|
Args:
|
|
room_id: Matrix room ID
|
|
query: Search text
|
|
limit: Maximum number of results (default: 10)
|
|
|
|
Returns:
|
|
List of matching messages with sender, text, timestamp
|
|
|
|
Examples:
|
|
search_messages("!abc:matrix.org", "meeting", limit=5)
|
|
"""
|
|
try:
|
|
# Load credentials
|
|
creds = load_credentials()
|
|
|
|
# Create Matrix client
|
|
MATRIX_DATA_DIR.mkdir(exist_ok=True)
|
|
client = AsyncClient(
|
|
homeserver=creds['homeserver'],
|
|
user=creds['user_id'],
|
|
store_path=str(MATRIX_DATA_DIR),
|
|
)
|
|
|
|
# Restore session
|
|
client.access_token = creds['access_token']
|
|
client.device_id = creds['device_id']
|
|
|
|
# Get room messages
|
|
response = await client.room_messages(
|
|
room_id=room_id,
|
|
start="",
|
|
limit=100 # Search last 100 messages
|
|
)
|
|
|
|
results = []
|
|
|
|
if isinstance(response, RoomMessagesResponse):
|
|
# Search through messages
|
|
for event in response.chunk:
|
|
if hasattr(event, 'body') and query.lower() in event.body.lower():
|
|
results.append({
|
|
"sender": event.sender,
|
|
"message": event.body,
|
|
"timestamp": datetime.fromtimestamp(event.server_timestamp / 1000).isoformat(),
|
|
"event_id": event.event_id,
|
|
})
|
|
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
await client.close()
|
|
|
|
logger.info(f"Found {len(results)} messages matching '{query}'")
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to search messages: {e}")
|
|
return [{"error": str(e)}]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logger.info("=" * 60)
|
|
logger.info("Matrix Control MCP Server starting...")
|
|
logger.info(f"State file: {STATE_FILE}")
|
|
logger.info(f"Credentials file: {CREDENTIALS_FILE}")
|
|
logger.info("=" * 60)
|
|
|
|
# Run MCP server (uses stdio transport by default)
|
|
mcp.run()
|