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:
root
2026-03-31 22:04:49 +02:00
commit 80f1a97e56
18 changed files with 3333 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for Discord-OpenClaw client."""

120
tests/test_config.py Normal file
View 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
View 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
View 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
View 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"