feat: Add Memory system with Weaviate integration and MCP tools
MEMORY SYSTEM ARCHITECTURE: - Weaviate-based memory storage (Thought, Message, Conversation collections) - GPU embeddings with BAAI/bge-m3 (1024-dim, RTX 4070) - 9 MCP tools for Claude Desktop integration CORE MODULES (memory/): - core/embedding_service.py: GPU embedder singleton with PyTorch - schemas/memory_schemas.py: Weaviate schema definitions - mcp/thought_tools.py: add_thought, search_thoughts, get_thought - mcp/message_tools.py: add_message, get_messages, search_messages - mcp/conversation_tools.py: get_conversation, search_conversations, list_conversations FLASK TEMPLATES: - conversation_view.html: Display single conversation with messages - conversations.html: List all conversations with search - memories.html: Browse and search thoughts FEATURES: - Semantic search across thoughts, messages, conversations - Privacy levels (private, shared, public) - Thought types (reflection, question, intuition, observation) - Conversation categories with filtering - Message ordering and role-based display DATA (as of 2026-01-08): - 102 Thoughts - 377 Messages - 12 Conversations DOCUMENTATION: - memory/README_MCP_TOOLS.md: Complete API reference and usage examples All MCP tools tested and validated (see test_memory_mcp_tools.py in archive). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
56
memory/mcp/__init__.py
Normal file
56
memory/mcp/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Memory MCP Tools Package.
|
||||
|
||||
Provides MCP tools for Memory system (Thoughts, Messages, Conversations).
|
||||
"""
|
||||
|
||||
from memory.mcp.thought_tools import (
|
||||
AddThoughtInput,
|
||||
SearchThoughtsInput,
|
||||
add_thought_handler,
|
||||
search_thoughts_handler,
|
||||
get_thought_handler,
|
||||
)
|
||||
|
||||
from memory.mcp.message_tools import (
|
||||
AddMessageInput,
|
||||
GetMessagesInput,
|
||||
SearchMessagesInput,
|
||||
add_message_handler,
|
||||
get_messages_handler,
|
||||
search_messages_handler,
|
||||
)
|
||||
|
||||
from memory.mcp.conversation_tools import (
|
||||
GetConversationInput,
|
||||
SearchConversationsInput,
|
||||
ListConversationsInput,
|
||||
get_conversation_handler,
|
||||
search_conversations_handler,
|
||||
list_conversations_handler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Thought tools
|
||||
"AddThoughtInput",
|
||||
"SearchThoughtsInput",
|
||||
"add_thought_handler",
|
||||
"search_thoughts_handler",
|
||||
"get_thought_handler",
|
||||
|
||||
# Message tools
|
||||
"AddMessageInput",
|
||||
"GetMessagesInput",
|
||||
"SearchMessagesInput",
|
||||
"add_message_handler",
|
||||
"get_messages_handler",
|
||||
"search_messages_handler",
|
||||
|
||||
# Conversation tools
|
||||
"GetConversationInput",
|
||||
"SearchConversationsInput",
|
||||
"ListConversationsInput",
|
||||
"get_conversation_handler",
|
||||
"search_conversations_handler",
|
||||
"list_conversations_handler",
|
||||
]
|
||||
208
memory/mcp/conversation_tools.py
Normal file
208
memory/mcp/conversation_tools.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Conversation MCP Tools - Handlers for conversation-related operations.
|
||||
|
||||
Provides tools for searching and retrieving conversations.
|
||||
"""
|
||||
|
||||
import weaviate
|
||||
from typing import Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from memory.core import get_embedder
|
||||
|
||||
|
||||
class GetConversationInput(BaseModel):
|
||||
"""Input for get_conversation tool."""
|
||||
conversation_id: str = Field(..., description="Conversation identifier")
|
||||
|
||||
|
||||
class SearchConversationsInput(BaseModel):
|
||||
"""Input for search_conversations tool."""
|
||||
query: str = Field(..., description="Search query text")
|
||||
limit: int = Field(default=10, ge=1, le=50, description="Maximum results")
|
||||
category_filter: str | None = Field(default=None, description="Filter by category")
|
||||
|
||||
|
||||
class ListConversationsInput(BaseModel):
|
||||
"""Input for list_conversations tool."""
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Maximum conversations")
|
||||
category_filter: str | None = Field(default=None, description="Filter by category")
|
||||
|
||||
|
||||
async def get_conversation_handler(input_data: GetConversationInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a specific conversation by ID.
|
||||
|
||||
Args:
|
||||
input_data: Query parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with conversation data.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get collection
|
||||
collection = client.collections.get("Conversation")
|
||||
|
||||
# Fetch by conversation_id
|
||||
results = collection.query.fetch_objects(
|
||||
filters=weaviate.classes.query.Filter.by_property("conversation_id").equal(input_data.conversation_id),
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if not results.objects:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Conversation {input_data.conversation_id} not found",
|
||||
}
|
||||
|
||||
obj = results.objects[0]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": obj.properties['conversation_id'],
|
||||
"category": obj.properties['category'],
|
||||
"summary": obj.properties['summary'],
|
||||
"timestamp_start": obj.properties['timestamp_start'],
|
||||
"timestamp_end": obj.properties['timestamp_end'],
|
||||
"participants": obj.properties['participants'],
|
||||
"tags": obj.properties.get('tags', []),
|
||||
"message_count": obj.properties['message_count'],
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def search_conversations_handler(input_data: SearchConversationsInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Search conversations using semantic similarity.
|
||||
|
||||
Args:
|
||||
input_data: Search parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with search results.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get embedder
|
||||
embedder = get_embedder()
|
||||
|
||||
# Generate query vector
|
||||
query_vector = embedder.embed_batch([input_data.query])[0]
|
||||
|
||||
# Get collection
|
||||
collection = client.collections.get("Conversation")
|
||||
|
||||
# Build query
|
||||
query_builder = collection.query.near_vector(
|
||||
near_vector=query_vector.tolist(),
|
||||
limit=input_data.limit,
|
||||
)
|
||||
|
||||
# Apply category filter if provided
|
||||
if input_data.category_filter:
|
||||
query_builder = query_builder.where(
|
||||
weaviate.classes.query.Filter.by_property("category").equal(input_data.category_filter)
|
||||
)
|
||||
|
||||
# Execute search
|
||||
results = query_builder.objects
|
||||
|
||||
# Format results
|
||||
conversations = []
|
||||
for obj in results:
|
||||
conversations.append({
|
||||
"conversation_id": obj.properties['conversation_id'],
|
||||
"category": obj.properties['category'],
|
||||
"summary": obj.properties['summary'],
|
||||
"timestamp_start": obj.properties['timestamp_start'],
|
||||
"timestamp_end": obj.properties['timestamp_end'],
|
||||
"participants": obj.properties['participants'],
|
||||
"message_count": obj.properties['message_count'],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": input_data.query,
|
||||
"results": conversations,
|
||||
"count": len(conversations),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def list_conversations_handler(input_data: ListConversationsInput) -> Dict[str, Any]:
|
||||
"""
|
||||
List all conversations with filtering.
|
||||
|
||||
Args:
|
||||
input_data: Query parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with conversation list.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get collection
|
||||
collection = client.collections.get("Conversation")
|
||||
|
||||
# Build query
|
||||
if input_data.category_filter:
|
||||
results = collection.query.fetch_objects(
|
||||
filters=weaviate.classes.query.Filter.by_property("category").equal(input_data.category_filter),
|
||||
limit=input_data.limit,
|
||||
)
|
||||
else:
|
||||
results = collection.query.fetch_objects(
|
||||
limit=input_data.limit,
|
||||
)
|
||||
|
||||
# Format results
|
||||
conversations = []
|
||||
for obj in results.objects:
|
||||
conversations.append({
|
||||
"conversation_id": obj.properties['conversation_id'],
|
||||
"category": obj.properties['category'],
|
||||
"summary": obj.properties['summary'][:100] + "..." if len(obj.properties['summary']) > 100 else obj.properties['summary'],
|
||||
"timestamp_start": obj.properties['timestamp_start'],
|
||||
"message_count": obj.properties['message_count'],
|
||||
"participants": obj.properties['participants'],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversations": conversations,
|
||||
"count": len(conversations),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
213
memory/mcp/message_tools.py
Normal file
213
memory/mcp/message_tools.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Message MCP Tools - Handlers for message-related operations.
|
||||
|
||||
Provides tools for adding, searching, and retrieving conversation messages.
|
||||
"""
|
||||
|
||||
import weaviate
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from memory.core import get_embedder
|
||||
|
||||
|
||||
class AddMessageInput(BaseModel):
|
||||
"""Input for add_message tool."""
|
||||
content: str = Field(..., description="Message content")
|
||||
role: str = Field(..., description="Role: user, assistant, system")
|
||||
conversation_id: str = Field(..., description="Conversation identifier")
|
||||
order_index: int = Field(default=0, description="Position in conversation")
|
||||
|
||||
|
||||
class GetMessagesInput(BaseModel):
|
||||
"""Input for get_messages tool."""
|
||||
conversation_id: str = Field(..., description="Conversation identifier")
|
||||
limit: int = Field(default=50, ge=1, le=500, description="Maximum messages")
|
||||
|
||||
|
||||
class SearchMessagesInput(BaseModel):
|
||||
"""Input for search_messages tool."""
|
||||
query: str = Field(..., description="Search query text")
|
||||
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
|
||||
conversation_id_filter: str | None = Field(default=None, description="Filter by conversation")
|
||||
|
||||
|
||||
async def add_message_handler(input_data: AddMessageInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new message to Weaviate.
|
||||
|
||||
Args:
|
||||
input_data: Message data to add.
|
||||
|
||||
Returns:
|
||||
Dictionary with success status and message UUID.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get embedder
|
||||
embedder = get_embedder()
|
||||
|
||||
# Generate vector for message content
|
||||
vector = embedder.embed_batch([input_data.content])[0]
|
||||
|
||||
# Get collection
|
||||
collection = client.collections.get("Message")
|
||||
|
||||
# Insert message
|
||||
uuid = collection.data.insert(
|
||||
properties={
|
||||
"content": input_data.content,
|
||||
"role": input_data.role,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"conversation_id": input_data.conversation_id,
|
||||
"order_index": input_data.order_index,
|
||||
"conversation": {
|
||||
"conversation_id": input_data.conversation_id,
|
||||
"category": "general", # Default
|
||||
},
|
||||
},
|
||||
vector=vector.tolist()
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"uuid": str(uuid),
|
||||
"content": input_data.content[:100] + "..." if len(input_data.content) > 100 else input_data.content,
|
||||
"role": input_data.role,
|
||||
"conversation_id": input_data.conversation_id,
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def get_messages_handler(input_data: GetMessagesInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all messages from a conversation.
|
||||
|
||||
Args:
|
||||
input_data: Query parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with messages in order.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get collection
|
||||
collection = client.collections.get("Message")
|
||||
|
||||
# Fetch messages for conversation
|
||||
results = collection.query.fetch_objects(
|
||||
filters=weaviate.classes.query.Filter.by_property("conversation_id").equal(input_data.conversation_id),
|
||||
limit=input_data.limit,
|
||||
)
|
||||
|
||||
# Sort by order_index
|
||||
messages = []
|
||||
for obj in results.objects:
|
||||
messages.append({
|
||||
"uuid": str(obj.uuid),
|
||||
"content": obj.properties['content'],
|
||||
"role": obj.properties['role'],
|
||||
"timestamp": obj.properties['timestamp'],
|
||||
"order_index": obj.properties['order_index'],
|
||||
})
|
||||
|
||||
# Sort by order_index
|
||||
messages.sort(key=lambda m: m['order_index'])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": input_data.conversation_id,
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def search_messages_handler(input_data: SearchMessagesInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Search messages using semantic similarity.
|
||||
|
||||
Args:
|
||||
input_data: Search parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with search results.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get embedder
|
||||
embedder = get_embedder()
|
||||
|
||||
# Generate query vector
|
||||
query_vector = embedder.embed_batch([input_data.query])[0]
|
||||
|
||||
# Get collection
|
||||
collection = client.collections.get("Message")
|
||||
|
||||
# Build query
|
||||
query_builder = collection.query.near_vector(
|
||||
near_vector=query_vector.tolist(),
|
||||
limit=input_data.limit,
|
||||
)
|
||||
|
||||
# Apply conversation filter if provided
|
||||
if input_data.conversation_id_filter:
|
||||
query_builder = query_builder.where(
|
||||
weaviate.classes.query.Filter.by_property("conversation_id").equal(input_data.conversation_id_filter)
|
||||
)
|
||||
|
||||
# Execute search
|
||||
results = query_builder.objects
|
||||
|
||||
# Format results
|
||||
messages = []
|
||||
for obj in results:
|
||||
messages.append({
|
||||
"uuid": str(obj.uuid),
|
||||
"content": obj.properties['content'],
|
||||
"role": obj.properties['role'],
|
||||
"timestamp": obj.properties['timestamp'],
|
||||
"conversation_id": obj.properties['conversation_id'],
|
||||
"order_index": obj.properties['order_index'],
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": input_data.query,
|
||||
"results": messages,
|
||||
"count": len(messages),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
203
memory/mcp/thought_tools.py
Normal file
203
memory/mcp/thought_tools.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Thought MCP Tools - Handlers for thought-related operations.
|
||||
|
||||
Provides tools for adding, searching, and retrieving thoughts from Weaviate.
|
||||
"""
|
||||
|
||||
import weaviate
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from memory.core import get_embedder
|
||||
|
||||
|
||||
class AddThoughtInput(BaseModel):
|
||||
"""Input for add_thought tool."""
|
||||
content: str = Field(..., description="The thought content")
|
||||
thought_type: str = Field(default="reflection", description="Type: reflection, question, intuition, observation, etc.")
|
||||
trigger: str = Field(default="", description="What triggered this thought")
|
||||
concepts: list[str] = Field(default_factory=list, description="Related concepts/tags")
|
||||
privacy_level: str = Field(default="private", description="Privacy: private, shared, public")
|
||||
|
||||
|
||||
class SearchThoughtsInput(BaseModel):
|
||||
"""Input for search_thoughts tool."""
|
||||
query: str = Field(..., description="Search query text")
|
||||
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
|
||||
thought_type_filter: str | None = Field(default=None, description="Filter by thought type")
|
||||
|
||||
|
||||
async def add_thought_handler(input_data: AddThoughtInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new thought to Weaviate.
|
||||
|
||||
Args:
|
||||
input_data: Thought data to add.
|
||||
|
||||
Returns:
|
||||
Dictionary with success status and thought UUID.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get embedder
|
||||
embedder = get_embedder()
|
||||
|
||||
# Generate vector for thought content
|
||||
vector = embedder.embed_batch([input_data.content])[0]
|
||||
|
||||
# Get collection
|
||||
collection = client.collections.get("Thought")
|
||||
|
||||
# Insert thought
|
||||
uuid = collection.data.insert(
|
||||
properties={
|
||||
"content": input_data.content,
|
||||
"thought_type": input_data.thought_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"trigger": input_data.trigger,
|
||||
"concepts": input_data.concepts,
|
||||
"privacy_level": input_data.privacy_level,
|
||||
"emotional_state": "",
|
||||
"context": "",
|
||||
},
|
||||
vector=vector.tolist()
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"uuid": str(uuid),
|
||||
"content": input_data.content[:100] + "..." if len(input_data.content) > 100 else input_data.content,
|
||||
"thought_type": input_data.thought_type,
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def search_thoughts_handler(input_data: SearchThoughtsInput) -> Dict[str, Any]:
|
||||
"""
|
||||
Search thoughts using semantic similarity.
|
||||
|
||||
Args:
|
||||
input_data: Search parameters.
|
||||
|
||||
Returns:
|
||||
Dictionary with search results.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get embedder
|
||||
embedder = get_embedder()
|
||||
|
||||
# Generate query vector
|
||||
query_vector = embedder.embed_batch([input_data.query])[0]
|
||||
|
||||
# Get collection
|
||||
collection = client.collections.get("Thought")
|
||||
|
||||
# Build query
|
||||
query = collection.query.near_vector(
|
||||
near_vector=query_vector.tolist(),
|
||||
limit=input_data.limit,
|
||||
)
|
||||
|
||||
# Apply thought_type filter if provided
|
||||
if input_data.thought_type_filter:
|
||||
query = query.where({
|
||||
"path": ["thought_type"],
|
||||
"operator": "Equal",
|
||||
"valueText": input_data.thought_type_filter,
|
||||
})
|
||||
|
||||
# Execute search
|
||||
results = query.objects
|
||||
|
||||
# Format results
|
||||
thoughts = []
|
||||
for obj in results:
|
||||
thoughts.append({
|
||||
"uuid": str(obj.uuid),
|
||||
"content": obj.properties['content'],
|
||||
"thought_type": obj.properties['thought_type'],
|
||||
"timestamp": obj.properties['timestamp'],
|
||||
"trigger": obj.properties.get('trigger', ''),
|
||||
"concepts": obj.properties.get('concepts', []),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": input_data.query,
|
||||
"results": thoughts,
|
||||
"count": len(thoughts),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def get_thought_handler(uuid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a specific thought by UUID.
|
||||
|
||||
Args:
|
||||
uuid: Thought UUID.
|
||||
|
||||
Returns:
|
||||
Dictionary with thought data.
|
||||
"""
|
||||
try:
|
||||
# Connect to Weaviate
|
||||
client = weaviate.connect_to_local()
|
||||
|
||||
try:
|
||||
# Get collection
|
||||
collection = client.collections.get("Thought")
|
||||
|
||||
# Fetch by UUID
|
||||
obj = collection.query.fetch_object_by_id(uuid)
|
||||
|
||||
if not obj:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Thought {uuid} not found",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"uuid": str(obj.uuid),
|
||||
"content": obj.properties['content'],
|
||||
"thought_type": obj.properties['thought_type'],
|
||||
"timestamp": obj.properties['timestamp'],
|
||||
"trigger": obj.properties.get('trigger', ''),
|
||||
"concepts": obj.properties.get('concepts', []),
|
||||
"privacy_level": obj.properties.get('privacy_level', 'private'),
|
||||
"emotional_state": obj.properties.get('emotional_state', ''),
|
||||
"context": obj.properties.get('context', ''),
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
Reference in New Issue
Block a user