Initial commit: Discord User Bot Client for OpenClaw
- Complete project structure with pyproject.toml - Configuration module with Pydantic validation - SQLite database with async operations - WebSocket client with capabilities negotiation - Discord client using discord.py-self - Message mapper for bidirectional conversion - State manager for user mappings and pairing - Unit tests for all components - Comprehensive README documentation Phase 1-4 complete, ready for testing
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Discord-OpenClaw client."""
|
||||
120
tests/test_config.py
Normal file
120
tests/test_config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.config import (
|
||||
Config,
|
||||
DiscordConfig,
|
||||
OpenClawConfig,
|
||||
DatabaseConfig,
|
||||
LoggingConfig,
|
||||
FeaturesConfig,
|
||||
SecurityConfig,
|
||||
load_config,
|
||||
)
|
||||
|
||||
|
||||
def test_discord_config_valid():
|
||||
"""Test valid Discord configuration."""
|
||||
config = DiscordConfig(token="test_token")
|
||||
assert config.token.get_secret_value() == "test_token"
|
||||
assert config.channels == []
|
||||
assert config.rate_limit == 1.0
|
||||
|
||||
|
||||
def test_discord_config_with_channels():
|
||||
"""Test Discord configuration with channels."""
|
||||
config = DiscordConfig(
|
||||
token="test_token",
|
||||
channels=[123, 456],
|
||||
rate_limit=2.0,
|
||||
)
|
||||
assert config.channels == [123, 456]
|
||||
assert config.rate_limit == 2.0
|
||||
|
||||
|
||||
def test_openclaw_config_valid():
|
||||
"""Test valid OpenClaw configuration."""
|
||||
config = OpenClawConfig(
|
||||
websocket_url="ws://localhost:8765/ws",
|
||||
api_key="test_key",
|
||||
)
|
||||
assert config.websocket_url == "ws://localhost:8765/ws"
|
||||
assert config.api_key.get_secret_value() == "test_key"
|
||||
|
||||
|
||||
def test_database_config_defaults():
|
||||
"""Test database configuration defaults."""
|
||||
config = DatabaseConfig()
|
||||
assert config.path == "./state.db"
|
||||
|
||||
|
||||
def test_logging_config_defaults():
|
||||
"""Test logging configuration defaults."""
|
||||
config = LoggingConfig()
|
||||
assert config.level == "INFO"
|
||||
assert config.format == "text"
|
||||
assert config.file is None
|
||||
|
||||
|
||||
def test_features_config_defaults():
|
||||
"""Test features configuration defaults."""
|
||||
config = FeaturesConfig()
|
||||
assert config.enable_reactions is True
|
||||
assert config.enable_typing is True
|
||||
assert config.enable_threads is True
|
||||
assert config.enable_voice is False
|
||||
assert config.dm_security == "paired"
|
||||
|
||||
|
||||
def test_security_config_defaults():
|
||||
"""Test security configuration defaults."""
|
||||
config = SecurityConfig()
|
||||
assert config.pairing_code_ttl == 300
|
||||
assert config.max_message_length == 2000
|
||||
|
||||
|
||||
def test_full_config():
|
||||
"""Test full configuration."""
|
||||
config = Config(
|
||||
discord=DiscordConfig(token="discord_token"),
|
||||
openclaw=OpenClawConfig(
|
||||
websocket_url="ws://localhost:8765/ws",
|
||||
api_key="api_key",
|
||||
),
|
||||
)
|
||||
assert config.discord.token.get_secret_value() == "discord_token"
|
||||
assert config.openclaw.websocket_url == "ws://localhost:8765/ws"
|
||||
|
||||
|
||||
def test_rate_limit_bounds():
|
||||
"""Test rate limit validation."""
|
||||
# Valid bounds
|
||||
config = DiscordConfig(token="test", rate_limit=0.1)
|
||||
assert config.rate_limit == 0.1
|
||||
|
||||
config = DiscordConfig(token="test", rate_limit=10.0)
|
||||
assert config.rate_limit == 10.0
|
||||
|
||||
# Invalid bounds
|
||||
with pytest.raises(ValidationError):
|
||||
DiscordConfig(token="test", rate_limit=0.0)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DiscordConfig(token="test", rate_limit=11.0)
|
||||
|
||||
|
||||
def test_dm_security_values():
|
||||
"""Test DM security configuration values."""
|
||||
config = FeaturesConfig(dm_security="open")
|
||||
assert config.dm_security == "open"
|
||||
|
||||
config = FeaturesConfig(dm_security="allowlist")
|
||||
assert config.dm_security == "allowlist"
|
||||
|
||||
config = FeaturesConfig(dm_security="paired")
|
||||
assert config.dm_security == "paired"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
FeaturesConfig(dm_security="invalid")
|
||||
145
tests/test_database.py
Normal file
145
tests/test_database.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for database module."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
"""Create a temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||
db_path = f.name
|
||||
|
||||
database = Database(db_path)
|
||||
await database.connect()
|
||||
yield database
|
||||
await database.disconnect()
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_and_disconnect(db):
|
||||
"""Test database connection."""
|
||||
assert db._db is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_mapping_crud(db):
|
||||
"""Test user mapping CRUD operations."""
|
||||
# Create mapping
|
||||
await db.create_user_mapping("discord123", "ws_user_456")
|
||||
|
||||
# Read mapping
|
||||
mapping = await db.get_user_mapping("discord123")
|
||||
assert mapping is not None
|
||||
assert mapping["discord_id"] == "discord123"
|
||||
assert mapping["websocket_user_id"] == "ws_user_456"
|
||||
|
||||
# Read by WebSocket ID
|
||||
mapping = await db.get_user_mapping_by_ws("ws_user_456")
|
||||
assert mapping is not None
|
||||
assert mapping["discord_id"] == "discord123"
|
||||
|
||||
# Update mapping
|
||||
await db.create_user_mapping("discord123", "ws_user_new")
|
||||
mapping = await db.get_user_mapping("discord123")
|
||||
assert mapping["websocket_user_id"] == "ws_user_new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_code_crud(db):
|
||||
"""Test pairing code operations."""
|
||||
# Create pairing code
|
||||
await db.create_pairing_code("ABC123", "discord123", "ws_user_456", ttl_seconds=300)
|
||||
|
||||
# Get pairing code
|
||||
code = await db.get_pairing_code("ABC123")
|
||||
assert code is not None
|
||||
assert code["discord_id"] == "discord123"
|
||||
assert code["used"] == 0 # SQLite stores boolean as 0/1
|
||||
|
||||
# Use pairing code
|
||||
result = await db.use_pairing_code("ABC123")
|
||||
assert result is True
|
||||
|
||||
# Code should no longer be valid
|
||||
code = await db.get_pairing_code("ABC123")
|
||||
assert code is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlist_crud(db):
|
||||
"""Test allowlist operations."""
|
||||
# Add to allowlist
|
||||
await db.add_to_allowlist("discord123", "ws_user_456", notes="Test user")
|
||||
|
||||
# Check if allowlisted
|
||||
assert await db.is_allowlisted("discord123") is True
|
||||
assert await db.is_allowlisted("discord999") is False
|
||||
|
||||
# Remove from allowlist
|
||||
result = await db.remove_from_allowlist("discord123")
|
||||
assert result is True
|
||||
assert await db.is_allowlisted("discord123") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_state(db):
|
||||
"""Test connection state operations."""
|
||||
# Set state
|
||||
await db.set_state("last_message_id", "12345")
|
||||
|
||||
# Get state
|
||||
value = await db.get_state("last_message_id")
|
||||
assert value == "12345"
|
||||
|
||||
# Get non-existent state
|
||||
value = await db.get_state("nonexistent")
|
||||
assert value is None
|
||||
|
||||
# Update state
|
||||
await db.set_state("last_message_id", "67890")
|
||||
value = await db.get_state("last_message_id")
|
||||
assert value == "67890"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_log(db):
|
||||
"""Test message logging."""
|
||||
# Log message
|
||||
await db.log_message(
|
||||
direction="discord_to_openclaw",
|
||||
discord_id="discord123",
|
||||
websocket_user_id="ws_user_456",
|
||||
message_type="text",
|
||||
content="Hello!",
|
||||
)
|
||||
|
||||
# Get recent messages
|
||||
messages = await db.get_recent_messages(limit=10)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["direction"] == "discord_to_openclaw"
|
||||
assert messages[0]["discord_id"] == "discord123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_expired_codes(db):
|
||||
"""Test cleanup of expired codes."""
|
||||
# Create an expired code (TTL = 0 seconds)
|
||||
await db.create_pairing_code("EXPIRED", "discord123", "ws_user_456", ttl_seconds=0)
|
||||
|
||||
# Wait a moment
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Cleanup
|
||||
count = await db.cleanup_expired_codes()
|
||||
assert count >= 1
|
||||
|
||||
# Code should be gone
|
||||
code = await db.get_pairing_code("EXPIRED")
|
||||
assert code is None
|
||||
209
tests/test_mapper.py
Normal file
209
tests/test_mapper.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Tests for message mapper module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.mapper import (
|
||||
TextContent,
|
||||
ImageContent,
|
||||
FileContent,
|
||||
VoiceContent,
|
||||
MessageMapper,
|
||||
)
|
||||
|
||||
|
||||
def test_text_content_to_dict():
|
||||
"""Test text content conversion."""
|
||||
content = TextContent(text="Hello!")
|
||||
data = content.to_dict()
|
||||
assert data["type"] == "text"
|
||||
assert data["text"] == "Hello!"
|
||||
|
||||
|
||||
def test_image_content_to_dict():
|
||||
"""Test image content conversion."""
|
||||
content = ImageContent(
|
||||
url="https://example.com/image.png",
|
||||
caption="An image",
|
||||
filename="image.png",
|
||||
)
|
||||
data = content.to_dict()
|
||||
assert data["type"] == "image"
|
||||
assert data["url"] == "https://example.com/image.png"
|
||||
assert data["caption"] == "An image"
|
||||
assert data["filename"] == "image.png"
|
||||
|
||||
|
||||
def test_image_content_no_caption():
|
||||
"""Test image content without caption."""
|
||||
content = ImageContent(url="https://example.com/image.png")
|
||||
data = content.to_dict()
|
||||
assert "caption" not in data or data.get("caption") is None
|
||||
|
||||
|
||||
def test_file_content_to_dict():
|
||||
"""Test file content conversion."""
|
||||
content = FileContent(
|
||||
url="https://example.com/file.pdf",
|
||||
filename="document.pdf",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
data = content.to_dict()
|
||||
assert data["type"] == "file"
|
||||
assert data["url"] == "https://example.com/file.pdf"
|
||||
assert data["filename"] == "document.pdf"
|
||||
assert data["mimeType"] == "application/pdf"
|
||||
|
||||
|
||||
def test_voice_content_to_dict():
|
||||
"""Test voice content conversion."""
|
||||
content = VoiceContent(
|
||||
url="https://example.com/voice.ogg",
|
||||
duration=5.5,
|
||||
transcript="Hello world",
|
||||
)
|
||||
data = content.to_dict()
|
||||
assert data["type"] == "voice"
|
||||
assert data["url"] == "https://example.com/voice.ogg"
|
||||
assert data["duration"] == 5.5
|
||||
assert data["transcript"] == "Hello world"
|
||||
|
||||
|
||||
def test_openclaw_to_discord_text():
|
||||
"""Test converting OpenClaw text to Discord."""
|
||||
message = {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello Discord!",
|
||||
}
|
||||
}
|
||||
text, kwargs = MessageMapper.openclaw_to_discord(message)
|
||||
assert text == "Hello Discord!"
|
||||
assert kwargs == {}
|
||||
|
||||
|
||||
def test_openclaw_to_discord_image():
|
||||
"""Test converting OpenClaw image to Discord."""
|
||||
message = {
|
||||
"content": {
|
||||
"type": "image",
|
||||
"url": "https://example.com/image.png",
|
||||
"caption": "A picture",
|
||||
}
|
||||
}
|
||||
text, kwargs = MessageMapper.openclaw_to_discord(message)
|
||||
assert "A picture" in text
|
||||
assert "https://example.com/image.png" in text
|
||||
|
||||
|
||||
def test_openclaw_to_discord_file():
|
||||
"""Test converting OpenClaw file to Discord."""
|
||||
message = {
|
||||
"content": {
|
||||
"type": "file",
|
||||
"url": "https://example.com/doc.pdf",
|
||||
"filename": "document.pdf",
|
||||
}
|
||||
}
|
||||
text, kwargs = MessageMapper.openclaw_to_discord(message)
|
||||
assert "document.pdf" in text
|
||||
assert "https://example.com/doc.pdf" in text
|
||||
|
||||
|
||||
def test_openclaw_to_discord_voice():
|
||||
"""Test converting OpenClaw voice to Discord."""
|
||||
message = {
|
||||
"content": {
|
||||
"type": "voice",
|
||||
"url": "https://example.com/voice.ogg",
|
||||
"duration": 10,
|
||||
"transcript": "Hello",
|
||||
}
|
||||
}
|
||||
text, kwargs = MessageMapper.openclaw_to_discord(message)
|
||||
assert "Voice message" in text
|
||||
assert "10s" in text
|
||||
assert "Hello" in text
|
||||
|
||||
|
||||
def test_discord_to_openclaw_text():
|
||||
"""Test converting Discord text to OpenClaw."""
|
||||
messages = MessageMapper.discord_to_openclaw(
|
||||
discord_id="123456",
|
||||
content="Hello OpenClaw!",
|
||||
channel_id="789",
|
||||
message_id="999",
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["type"] == "message"
|
||||
assert messages[0]["from"] == "discord:123456"
|
||||
assert messages[0]["content"]["type"] == "text"
|
||||
assert messages[0]["content"]["text"] == "Hello OpenClaw!"
|
||||
|
||||
|
||||
def test_discord_to_openclaw_with_image():
|
||||
"""Test converting Discord message with image to OpenClaw."""
|
||||
messages = MessageMapper.discord_to_openclaw(
|
||||
discord_id="123456",
|
||||
content="Check this out!",
|
||||
channel_id="789",
|
||||
message_id="999",
|
||||
attachments=[
|
||||
{
|
||||
"filename": "image.png",
|
||||
"url": "https://example.com/image.png",
|
||||
"content_type": "image/png",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(messages) == 2
|
||||
# First message is text
|
||||
assert messages[0]["content"]["type"] == "text"
|
||||
# Second message is image
|
||||
assert messages[1]["content"]["type"] == "image"
|
||||
assert messages[1]["content"]["url"] == "https://example.com/image.png"
|
||||
|
||||
|
||||
def test_discord_to_openclaw_voice():
|
||||
"""Test converting Discord voice message to OpenClaw."""
|
||||
messages = MessageMapper.discord_to_openclaw(
|
||||
discord_id="123456",
|
||||
content="",
|
||||
channel_id="789",
|
||||
message_id="999",
|
||||
attachments=[
|
||||
{
|
||||
"filename": "voice-message.ogg",
|
||||
"url": "https://example.com/voice.ogg",
|
||||
"content_type": "audio/ogg",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["content"]["type"] == "voice"
|
||||
|
||||
|
||||
def test_reaction_to_openclaw():
|
||||
"""Test converting reaction to OpenClaw."""
|
||||
msg = MessageMapper.reaction_to_openclaw(
|
||||
discord_id="123456",
|
||||
message_id="999",
|
||||
emoji="👍",
|
||||
action="add",
|
||||
channel_id="789",
|
||||
)
|
||||
assert msg["type"] == "reaction"
|
||||
assert msg["from"] == "discord:123456"
|
||||
assert msg["messageId"] == "999"
|
||||
assert msg["emoji"] == "👍"
|
||||
assert msg["action"] == "add"
|
||||
|
||||
|
||||
def test_typing_to_openclaw():
|
||||
"""Test converting typing to OpenClaw."""
|
||||
msg = MessageMapper.typing_to_openclaw(
|
||||
discord_id="123456",
|
||||
is_typing=True,
|
||||
)
|
||||
assert msg["type"] == "typing"
|
||||
assert msg["from"] == "discord:123456"
|
||||
assert msg["isTyping"] is True
|
||||
78
tests/test_websocket.py
Normal file
78
tests/test_websocket.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for WebSocket client module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.websocket_client import (
|
||||
ConnectionState,
|
||||
ServerCapabilities,
|
||||
ClientCapabilities,
|
||||
)
|
||||
|
||||
|
||||
def test_server_capabilities_from_dict():
|
||||
"""Test creating server capabilities from dict."""
|
||||
data = {
|
||||
"text": True,
|
||||
"images": True,
|
||||
"files": False,
|
||||
"voice": False,
|
||||
"reactions": True,
|
||||
"threads": True,
|
||||
"typing": True,
|
||||
}
|
||||
caps = ServerCapabilities.from_dict(data)
|
||||
assert caps.text is True
|
||||
assert caps.images is True
|
||||
assert caps.files is False
|
||||
assert caps.voice is False
|
||||
assert caps.reactions is True
|
||||
|
||||
|
||||
def test_server_capabilities_defaults():
|
||||
"""Test server capabilities defaults."""
|
||||
caps = ServerCapabilities()
|
||||
assert caps.text is False
|
||||
assert caps.images is False
|
||||
assert caps.files is False
|
||||
|
||||
|
||||
def test_server_capabilities_partial():
|
||||
"""Test server capabilities with partial data."""
|
||||
data = {"text": True}
|
||||
caps = ServerCapabilities.from_dict(data)
|
||||
assert caps.text is True
|
||||
assert caps.images is False
|
||||
|
||||
|
||||
def test_client_capabilities_to_dict():
|
||||
"""Test converting client capabilities to dict."""
|
||||
caps = ClientCapabilities(
|
||||
text=True,
|
||||
images=True,
|
||||
files=True,
|
||||
voice=False,
|
||||
reactions=True,
|
||||
threads=True,
|
||||
typing=True,
|
||||
)
|
||||
data = caps.to_dict()
|
||||
assert data["text"] is True
|
||||
assert data["images"] is True
|
||||
assert data["voice"] is False
|
||||
|
||||
|
||||
def test_client_capabilities_defaults():
|
||||
"""Test client capabilities defaults."""
|
||||
caps = ClientCapabilities()
|
||||
assert caps.text is True
|
||||
assert caps.images is True
|
||||
assert caps.voice is True
|
||||
|
||||
|
||||
def test_connection_state_values():
|
||||
"""Test connection state enum values."""
|
||||
assert ConnectionState.DISCONNECTED.value == "disconnected"
|
||||
assert ConnectionState.CONNECTING.value == "connecting"
|
||||
assert ConnectionState.CONNECTED.value == "connected"
|
||||
assert ConnectionState.AUTHENTICATED.value == "authenticated"
|
||||
assert ConnectionState.READY.value == "ready"
|
||||
Reference in New Issue
Block a user