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

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Environment variables (alternative to config.yaml)
# Copy to .env and fill in values
# Discord user token
DISCORD_TOKEN=your_discord_token_here
# OpenClaw WebSocket settings
OPENCLAW_WS_URL=ws://localhost:8765/ws
OPENCLAW_API_KEY=your_api_key_here
# Database path
DATABASE_PATH=./state.db
# Log level
LOG_LEVEL=INFO

73
.gitignore vendored Normal file
View File

@@ -0,0 +1,73 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Project specific
config.yaml
state.db
state.db-journal
logs/
*.log

262
README.md Normal file
View File

@@ -0,0 +1,262 @@
# Discord User Bot Client for OpenClaw WebSocket Channel
A Python client that bridges Discord user accounts with OpenClaw's WebSocket channel plugin using `discord.py-self`.
> ⚠️ **Warning:** This project uses `discord.py-self` which automates Discord user accounts. User automation is against Discord's Terms of Service. Use at your own risk. For production use, consider using the official Discord Bot API instead.
## Features
- **Full WebSocket Protocol Support** - Complete implementation of OpenClaw WebSocket protocol
- **Capabilities Negotiation** - Automatic handshake and capability exchange
- **Message Types** - Text, images, files, voice, reactions, threads, typing indicators
- **State Persistence** - SQLite database for mappings, pairing codes, and allowlist
- **Reconnection** - Automatic reconnection with exponential backoff
- **Rate Limiting** - Built-in rate limiting to avoid Discord detection
- **DM Security** - Configurable security modes: open, allowlist, or paired
## Requirements
- Python 3.10+
- Discord user token (see below)
- OpenClaw WebSocket server running
## Installation
### From Source
```bash
# Clone the repository
git clone https://github.com/your-repo/discord-openclaw-client.git
cd discord-openclaw-client
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -e ".[dev]"
```
### Using pip
```bash
pip install discord-openclaw-client
```
## Configuration
### Configuration File
Copy `config.example.yaml` to `config.yaml` and edit:
```bash
cp config.example.yaml config.yaml
```
Edit `config.yaml`:
```yaml
discord:
token: "YOUR_DISCORD_USER_TOKEN" # Required
channels:
- 123456789012345678 # Optional: channels to monitor
rate_limit: 1.0
openclaw:
websocket_url: "ws://localhost:8765/ws"
api_key: "sk_test_your_api_key" # Required
database:
path: "./state.db"
logging:
level: "INFO"
format: "text"
features:
enable_reactions: true
enable_typing: true
enable_threads: true
enable_voice: false
dm_security: "paired" # "open", "allowlist", or "paired"
```
### Environment Variables
Alternatively, use environment variables:
```bash
export DISCORD_TOKEN="your_discord_token"
export OPENCLAW_WS_URL="ws://localhost:8765/ws"
export OPENCLAW_API_KEY="your_api_key"
export DATABASE_PATH="./state.db"
export LOG_LEVEL="INFO"
```
### Getting a Discord User Token
⚠️ **Security Note:** Never share your Discord token. It provides full access to your account.
1. Open Discord in your browser
2. Open Developer Tools (F12)
3. Go to Network tab
4. Send a message or interact with Discord
5. Find a request and look for the `Authorization` header
6. Copy the token value
## Usage
### Running the Client
```bash
# With config file
discord-openclaw -c config.yaml
# Or with environment variables
discord-openclaw
```
### DM Security Modes
- **open** - Accept all DMs (not recommended)
- **allowlist** - Only accept DMs from allowlisted users
- **paired** - Only accept DMs from users who completed pairing
### Pairing Flow
When `dm_security` is set to `paired`, users must pair their Discord account:
1. User sends a message to the Discord account
2. Bot responds with pairing instructions
3. User enters pairing code in OpenClaw
4. After successful pairing, messages are relayed
## Database Schema
The client uses SQLite for persistence:
- **user_mappings** - Discord ID ↔ WebSocket user ID mappings
- **pairing_codes** - Temporary pairing codes
- **allowlist** - Allowed Discord users
- **connection_state** - Key-value state store
- **message_log** - Debug message log
## API Reference
### WebSocket Messages
#### From OpenClaw
```json
{
"type": "message",
"to": "discord:123456789",
"content": {
"type": "text",
"text": "Hello!"
}
}
```
#### To OpenClaw
```json
{
"type": "message",
"from": "discord:123456789",
"content": {
"type": "text",
"text": "Hello back!"
},
"metadata": {
"discord_id": "123456789",
"channel_id": "987654321",
"message_id": "111222333"
}
}
```
## Development
### Running Tests
```bash
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# With coverage
pytest --cov=src
```
### Code Style
```bash
# Format code
ruff format .
# Check linting
ruff check .
# Type checking
mypy src
```
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Discord API │────▶│ DiscordClient │────▶│ StateManager │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ MessageMapper │────▶│ Database │
└─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ WebSocketClient │────▶│ OpenClaw WS │
└─────────────────┘ └─────────────────┘
```
## Troubleshooting
### Connection Issues
- Check that OpenClaw WebSocket server is running
- Verify API key is correct
- Check network connectivity
### Discord Token Issues
- Token may have expired - get a new one
- Account may be flagged for automation
- Try using a different account
### Rate Limiting
If you're getting rate limited:
- Increase `rate_limit` value in config
- Reduce message frequency
- Consider using official Discord Bot API instead
## License
MIT License - See LICENSE file for details.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests and linting
5. Submit a pull request
## Disclaimer
This project is for educational and personal use only. Using automated user accounts violates Discord's Terms of Service. The authors are not responsible for any consequences of using this software, including but not limited to account suspension or termination.
For production use, please consider using the official Discord Bot API instead.

58
config.example.yaml Normal file
View File

@@ -0,0 +1,58 @@
# Discord User Bot Client Configuration
# Copy this file to config.yaml and fill in your values
discord:
# Your Discord user token (REQUIRED)
# How to get: https://github.com/dolfies/discord.py-self#logging-in
token: "YOUR_DISCORD_USER_TOKEN_HERE"
# Optional: specific channels to monitor (by ID)
# If empty, only DMs are monitored
channels:
# - 123456789012345678
# Rate limiting to avoid detection (messages per second)
rate_limit: 1.0
openclaw:
# WebSocket URL for OpenClaw
websocket_url: "ws://localhost:8765/ws"
# API key for authentication (REQUIRED)
api_key: "sk_test_your_api_key_here"
# Connection settings
reconnect_delay: 5 # Initial delay before reconnect (seconds)
max_reconnect_delay: 300 # Maximum reconnect delay (seconds)
ping_interval: 30 # Keepalive ping interval (seconds)
database:
# SQLite database file path
path: "./state.db"
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR
format: "text" # "text" or "json"
# Optional: log to file
# file: "./logs/client.log"
features:
# Enable/disable features
enable_reactions: true
enable_typing: true
enable_threads: true
enable_voice: false # Voice is complex, experimental
# DM security mode
# "open" - accept all DMs
# "allowlist" - only accept from allowlisted users
# "paired" - only accept from paired users
dm_security: "paired"
# Security settings
security:
# How long pairing codes are valid (seconds)
pairing_code_ttl: 300 # 5 minutes
# Maximum message length to send
max_message_length: 2000

60
pyproject.toml Normal file
View File

@@ -0,0 +1,60 @@
[project]
name = "discord-openclaw-client"
version = "1.0.0"
description = "Discord user bot client for OpenClaw WebSocket Channel"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "Azimkin", email = "dev@azimkin.dev"}
]
keywords = ["discord", "openclaw", "websocket", "client"]
dependencies = [
"discord.py-self>=2.0.0",
"websockets>=12.0",
"aiosqlite>=0.19.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"PyYAML>=6.0.1",
"structlog>=23.2.0",
"httpx>=0.25.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"mypy>=1.7.0",
"ruff>=0.1.0",
]
[project.scripts]
discord-openclaw = "src.main:main"
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "ANN", "B", "C4", "SIM"]
ignore = ["ANN101", "ANN102", "ANN401"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

4
src/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Discord User Bot Client for OpenClaw WebSocket Channel."""
__version__ = "1.0.0"
__author__ = "Azimkin"

154
src/config.py Normal file
View File

@@ -0,0 +1,154 @@
"""Configuration loading and validation using Pydantic."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field, SecretStr, field_validator
class DiscordConfig(BaseModel):
"""Discord connection settings."""
token: SecretStr = Field(..., description="Discord user token")
channels: list[int] = Field(default_factory=list, description="Channel IDs to monitor")
rate_limit: float = Field(default=1.0, ge=0.1, le=10.0, description="Messages per second")
class OpenClawConfig(BaseModel):
"""OpenClaw WebSocket settings."""
websocket_url: str = Field(..., description="WebSocket URL")
api_key: SecretStr = Field(..., description="API key for authentication")
reconnect_delay: float = Field(default=5.0, ge=1.0, description="Initial reconnect delay")
max_reconnect_delay: float = Field(default=300.0, ge=10.0, description="Max reconnect delay")
ping_interval: float = Field(default=30.0, ge=10.0, description="Ping interval for keepalive")
class DatabaseConfig(BaseModel):
"""Database settings."""
path: str = Field(default="./state.db", description="SQLite database path")
class LoggingConfig(BaseModel):
"""Logging settings."""
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO")
format: Literal["text", "json"] = Field(default="text")
file: str | None = Field(default=None, description="Optional log file path")
class FeaturesConfig(BaseModel):
"""Feature toggles."""
enable_reactions: bool = Field(default=True)
enable_typing: bool = Field(default=True)
enable_threads: bool = Field(default=True)
enable_voice: bool = Field(default=False)
dm_security: Literal["open", "allowlist", "paired"] = Field(default="paired")
class SecurityConfig(BaseModel):
"""Security settings."""
pairing_code_ttl: int = Field(default=300, ge=60, description="Pairing code TTL in seconds")
max_message_length: int = Field(default=2000, ge=1, le=4000)
class Config(BaseModel):
"""Root configuration."""
discord: DiscordConfig
openclaw: OpenClawConfig
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
features: FeaturesConfig = Field(default_factory=FeaturesConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
@field_validator("database")
@classmethod
def validate_database_path(cls, v: DatabaseConfig) -> DatabaseConfig:
"""Ensure database directory exists."""
db_path = Path(v.path)
db_dir = db_path.parent
if db_dir and not db_dir.exists():
db_dir.mkdir(parents=True, exist_ok=True)
return v
def load_config(config_path: str = "config.yaml") -> Config:
"""Load configuration from YAML file.
Args:
config_path: Path to the configuration file.
Returns:
Parsed and validated configuration.
Raises:
FileNotFoundError: If config file doesn't exist.
ValueError: If configuration is invalid.
"""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data:
raise ValueError(f"Empty configuration file: {config_path}")
return Config(**data)
def load_config_from_env() -> Config:
"""Load configuration from environment variables.
Returns:
Configuration from environment.
Raises:
ValueError: If required environment variables are missing.
"""
import os
discord_token = os.environ.get("DISCORD_TOKEN")
ws_url = os.environ.get("OPENCLAW_WS_URL", "ws://localhost:8765/ws")
api_key = os.environ.get("OPENCLAW_API_KEY")
if not discord_token:
raise ValueError("DISCORD_TOKEN environment variable is required")
if not api_key:
raise ValueError("OPENCLAW_API_KEY environment variable is required")
return Config(
discord=DiscordConfig(token=SecretStr(discord_token)),
openclaw=OpenClawConfig(
websocket_url=ws_url,
api_key=SecretStr(api_key),
),
database=DatabaseConfig(path=os.environ.get("DATABASE_PATH", "./state.db")),
logging=LoggingConfig(level=os.environ.get("LOG_LEVEL", "INFO")), # type: ignore
)
def get_config(config_path: str | None = None) -> Config:
"""Get configuration from file or environment.
Prefers config file if it exists, falls back to environment variables.
Args:
config_path: Optional path to config file. Defaults to config.yaml.
Returns:
Configuration object.
"""
path = config_path or "config.yaml"
try:
return load_config(path)
except FileNotFoundError:
return load_config_from_env()

387
src/database.py Normal file
View File

@@ -0,0 +1,387 @@
"""SQLite database operations for state persistence."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import aiosqlite
class Database:
"""Async SQLite database manager."""
def __init__(self, db_path: str = "./state.db") -> None:
"""Initialize database manager.
Args:
db_path: Path to SQLite database file.
"""
self.db_path = db_path
self._db: aiosqlite.Connection | None = None
async def connect(self) -> None:
"""Connect to database and initialize schema."""
# Ensure directory exists
db_path = Path(self.db_path)
if db_path.parent:
db_path.parent.mkdir(parents=True, exist_ok=True)
self._db = await aiosqlite.connect(self.db_path)
self._db.row_factory = aiosqlite.Row
await self._init_schema()
async def disconnect(self) -> None:
"""Disconnect from database."""
if self._db:
await self._db.close()
self._db = None
async def _init_schema(self) -> None:
"""Initialize database schema."""
if not self._db:
raise RuntimeError("Database not connected")
await self._db.executescript("""
-- User mappings (Discord ID <-> WebSocket user ID)
CREATE TABLE IF NOT EXISTS user_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT UNIQUE NOT NULL,
websocket_user_id TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Pairing codes for DM verification
CREATE TABLE IF NOT EXISTS pairing_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
discord_id TEXT NOT NULL,
websocket_user_id TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Allowlist entries
CREATE TABLE IF NOT EXISTS allowlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT UNIQUE NOT NULL,
websocket_user_id TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Connection state (key-value store)
CREATE TABLE IF NOT EXISTS connection_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Message log (for debugging)
CREATE TABLE IF NOT EXISTS message_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT NOT NULL,
discord_id TEXT,
websocket_user_id TEXT,
message_type TEXT NOT NULL,
content TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_pairing_code ON pairing_codes(code);
CREATE INDEX IF NOT EXISTS idx_pairing_expires ON pairing_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_allowlist_discord ON allowlist(discord_id);
""")
await self._db.commit()
# === User Mappings ===
async def get_user_mapping(self, discord_id: str) -> dict[str, Any] | None:
"""Get user mapping by Discord ID.
Args:
discord_id: Discord user ID.
Returns:
User mapping dict or None if not found.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"SELECT * FROM user_mappings WHERE discord_id = ?", (discord_id,)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_user_mapping_by_ws(self, websocket_user_id: str) -> dict[str, Any] | None:
"""Get user mapping by WebSocket user ID.
Args:
websocket_user_id: WebSocket user ID.
Returns:
User mapping dict or None if not found.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"SELECT * FROM user_mappings WHERE websocket_user_id = ?", (websocket_user_id,)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def create_user_mapping(self, discord_id: str, websocket_user_id: str) -> None:
"""Create or update user mapping.
Args:
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
"""
if not self._db:
raise RuntimeError("Database not connected")
await self._db.execute(
"""INSERT INTO user_mappings (discord_id, websocket_user_id, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(discord_id) DO UPDATE SET
websocket_user_id = excluded.websocket_user_id,
updated_at = CURRENT_TIMESTAMP""",
(discord_id, websocket_user_id),
)
await self._db.commit()
# === Pairing Codes ===
async def create_pairing_code(
self, code: str, discord_id: str, websocket_user_id: str, ttl_seconds: int = 300
) -> None:
"""Create a new pairing code.
Args:
code: Pairing code.
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
ttl_seconds: Time to live in seconds.
"""
if not self._db:
raise RuntimeError("Database not connected")
expires_at = datetime.utcnow() + timedelta(seconds=ttl_seconds)
await self._db.execute(
"""INSERT INTO pairing_codes (code, discord_id, websocket_user_id, expires_at)
VALUES (?, ?, ?, ?)""",
(code, discord_id, websocket_user_id, expires_at.isoformat()),
)
await self._db.commit()
async def get_pairing_code(self, code: str) -> dict[str, Any] | None:
"""Get pairing code details.
Args:
code: Pairing code to look up.
Returns:
Pairing code details or None if not found/expired.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"""SELECT * FROM pairing_codes
WHERE code = ? AND used = FALSE AND expires_at > CURRENT_TIMESTAMP""",
(code,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def use_pairing_code(self, code: str) -> bool:
"""Mark pairing code as used.
Args:
code: Pairing code to mark as used.
Returns:
True if code was valid and marked, False otherwise.
"""
if not self._db:
raise RuntimeError("Database not connected")
cursor = await self._db.execute(
"""UPDATE pairing_codes SET used = TRUE
WHERE code = ? AND used = FALSE AND expires_at > CURRENT_TIMESTAMP""",
(code,),
)
await self._db.commit()
return cursor.rowcount > 0
async def cleanup_expired_codes(self) -> int:
"""Remove expired pairing codes.
Returns:
Number of codes removed.
"""
if not self._db:
raise RuntimeError("Database not connected")
cursor = await self._db.execute(
"DELETE FROM pairing_codes WHERE expires_at < CURRENT_TIMESTAMP OR used = TRUE"
)
await self._db.commit()
return cursor.rowcount
# === Allowlist ===
async def add_to_allowlist(
self, discord_id: str, websocket_user_id: str, notes: str | None = None
) -> None:
"""Add user to allowlist.
Args:
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
notes: Optional notes.
"""
if not self._db:
raise RuntimeError("Database not connected")
await self._db.execute(
"""INSERT INTO allowlist (discord_id, websocket_user_id, notes)
VALUES (?, ?, ?)
ON CONFLICT(discord_id) DO UPDATE SET
websocket_user_id = excluded.websocket_user_id,
notes = excluded.notes""",
(discord_id, websocket_user_id, notes),
)
await self._db.commit()
async def is_allowlisted(self, discord_id: str) -> bool:
"""Check if user is allowlisted.
Args:
discord_id: Discord user ID.
Returns:
True if user is allowlisted.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"SELECT id FROM allowlist WHERE discord_id = ?", (discord_id,)
) as cursor:
return await cursor.fetchone() is not None
async def remove_from_allowlist(self, discord_id: str) -> bool:
"""Remove user from allowlist.
Args:
discord_id: Discord user ID.
Returns:
True if user was removed.
"""
if not self._db:
raise RuntimeError("Database not connected")
cursor = await self._db.execute(
"DELETE FROM allowlist WHERE discord_id = ?", (discord_id,)
)
await self._db.commit()
return cursor.rowcount > 0
# === Connection State ===
async def get_state(self, key: str) -> str | None:
"""Get connection state value.
Args:
key: State key.
Returns:
State value or None if not found.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"SELECT value FROM connection_state WHERE key = ?", (key,)
) as cursor:
row = await cursor.fetchone()
return row["value"] if row else None
async def set_state(self, key: str, value: str) -> None:
"""Set connection state value.
Args:
key: State key.
value: State value.
"""
if not self._db:
raise RuntimeError("Database not connected")
await self._db.execute(
"""INSERT INTO connection_state (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP""",
(key, value),
)
await self._db.commit()
# === Message Log ===
async def log_message(
self,
direction: str,
discord_id: str | None,
websocket_user_id: str | None,
message_type: str,
content: str | None,
) -> None:
"""Log message for debugging.
Args:
direction: 'discord_to_openclaw' or 'openclaw_to_discord'.
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
message_type: Message type.
content: Message content (truncated if too long).
"""
if not self._db:
raise RuntimeError("Database not connected")
# Truncate content if too long
if content and len(content) > 1000:
content = content[:1000] + "..."
await self._db.execute(
"""INSERT INTO message_log (direction, discord_id, websocket_user_id, message_type, content)
VALUES (?, ?, ?, ?, ?)""",
(direction, discord_id, websocket_user_id, message_type, content),
)
await self._db.commit()
async def get_recent_messages(self, limit: int = 100) -> list[dict[str, Any]]:
"""Get recent messages from log.
Args:
limit: Maximum number of messages to return.
Returns:
List of recent messages.
"""
if not self._db:
raise RuntimeError("Database not connected")
async with self._db.execute(
"""SELECT * FROM message_log ORDER BY timestamp DESC LIMIT ?""", (limit,)
) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]

477
src/discord_client.py Normal file
View File

@@ -0,0 +1,477 @@
"""Discord client using discord.py-self."""
from __future__ import annotations
import asyncio
from io import BytesIO
from pathlib import Path
from typing import Any, Callable
import discord
from discord import Message as DiscordMessage
from discord.enums import MessageType
from .config import Config
from .state import StateManager
# discord.py-self uses the same import path
# but provides user account functionality
import structlog
logger = structlog.get_logger()
class DiscordClient(discord.Client):
"""Discord client wrapper for user account."""
def __init__(
self,
config: Config,
state: StateManager,
on_openclaw_message: Callable[[dict[str, Any]], None] | None = None,
) -> None:
"""Initialize Discord client.
Args:
config: Application configuration.
state: State manager.
on_openclaw_message: Callback for messages to send to OpenClaw.
"""
# Configure intents for user account
intents = discord.Intents.default()
intents.message_content = True
intents.messages = True
intents.reactions = True
intents.typing = True
intents.dm_messages = True
intents.guild_messages = True
super().__init__(intents=intents)
self.config = config
self.state = state
self.on_openclaw_message = on_openclaw_message
self._rate_limit_delay = 1.0 / config.discord.rate_limit
self._last_message_time: float = 0
async def start_client(self) -> None:
"""Start the Discord client."""
token = self.config.discord.token.get_secret_value()
await self.start(token)
async def send_to_user(self, discord_id: str, content: str) -> DiscordMessage | None:
"""Send message to Discord user.
Args:
discord_id: Discord user ID.
content: Message content.
Returns:
Sent message or None on failure.
"""
try:
user = await self.fetch_user(int(discord_id))
if not user:
logger.warning("User not found", discord_id=discord_id)
return None
await self._rate_limit()
message = await user.send(content)
logger.debug("Sent DM", user=discord_id)
return message
except discord.Forbidden:
logger.error("Cannot send DM to user", user=discord_id)
return None
except Exception as e:
logger.error("Failed to send message", error=str(e), user=discord_id)
return None
async def send_to_channel(
self, channel_id: int, content: str
) -> DiscordMessage | None:
"""Send message to Discord channel.
Args:
channel_id: Channel ID.
content: Message content.
Returns:
Sent message or None on failure.
"""
try:
channel = self.get_channel(channel_id)
if not channel:
logger.warning("Channel not found", channel_id=channel_id)
return None
await self._rate_limit()
message = await channel.send(content)
logger.debug("Sent message to channel", channel=channel_id)
return message
except Exception as e:
logger.error("Failed to send to channel", error=str(e))
return None
async def send_image_to_user(
self, discord_id: str, url: str, caption: str | None = None
) -> DiscordMessage | None:
"""Send image to Discord user.
Args:
discord_id: Discord user ID.
url: Image URL.
caption: Optional caption.
Returns:
Sent message or None on failure.
"""
try:
user = await self.fetch_user(int(discord_id))
if not user:
return None
# Download image
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
file_data = BytesIO(response.content)
file = discord.File(file_data, filename="image.png")
await self._rate_limit()
message = await user.send(content=caption, file=file)
return message
except Exception as e:
logger.error("Failed to send image", error=str(e))
return None
async def send_file_to_user(
self,
discord_id: str,
url: str,
filename: str,
) -> DiscordMessage | None:
"""Send file to Discord user.
Args:
discord_id: Discord user ID.
url: File URL.
filename: File name.
Returns:
Sent message or None on failure.
"""
try:
user = await self.fetch_user(int(discord_id))
if not user:
return None
# Download file
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
file_data = BytesIO(response.content)
file = discord.File(file_data, filename=filename)
await self._rate_limit()
message = await user.send(file=file)
return message
except Exception as e:
logger.error("Failed to send file", error=str(e))
return None
async def add_reaction(
self, channel_id: int, message_id: int, emoji: str
) -> bool:
"""Add reaction to message.
Args:
channel_id: Channel ID.
message_id: Message ID.
emoji: Emoji to add.
Returns:
True if successful.
"""
try:
channel = self.get_channel(channel_id)
if not channel:
return False
message = await channel.fetch_message(message_id)
await message.add_reaction(emoji)
return True
except Exception as e:
logger.error("Failed to add reaction", error=str(e))
return False
async def remove_reaction(
self, channel_id: int, message_id: int, emoji: str, user_id: int | None = None
) -> bool:
"""Remove reaction from message.
Args:
channel_id: Channel ID.
message_id: Message ID.
emoji: Emoji to remove.
user_id: User ID (None for own reaction).
Returns:
True if successful.
"""
try:
channel = self.get_channel(channel_id)
if not channel:
return False
message = await channel.fetch_message(message_id)
if user_id:
user = await self.fetch_user(user_id)
await message.remove_reaction(emoji, user)
else:
await message.remove_reaction(emoji, self.user)
return True
except Exception as e:
logger.error("Failed to remove reaction", error=str(e))
return False
async def trigger_typing(self, discord_id: str) -> None:
"""Trigger typing indicator for user.
Args:
discord_id: Discord user ID.
"""
try:
user = await self.fetch_user(int(discord_id))
if user:
await user.trigger_typing()
except Exception as e:
logger.error("Failed to trigger typing", error=str(e))
async def _rate_limit(self) -> None:
"""Apply rate limiting."""
import time
now = time.time()
elapsed = now - self._last_message_time
if elapsed < self._rate_limit_delay:
await asyncio.sleep(self._rate_limit_delay - elapsed)
self._last_message_time = time.time()
# === Event Handlers ===
async def on_ready(self) -> None:
"""Handle ready event."""
logger.info(
"Discord client ready",
user=str(self.user),
guilds=len(self.guilds),
)
async def on_message(self, message: DiscordMessage) -> None:
"""Handle incoming message.
Args:
message: Discord message.
"""
# Ignore own messages
if message.author.id == self.user.id:
return
# Ignore non-text messages
if message.type != MessageType.default:
return
# Check if DM or monitored channel
is_dm = isinstance(message.channel, discord.DMChannel)
is_monitored = message.channel.id in self.config.discord.channels
if not is_dm and not is_monitored:
return
# Check security for DMs
if is_dm:
discord_id = str(message.author.id)
if not await self.state.is_user_allowed(
discord_id, self.config.features.dm_security
):
logger.debug("DM from unauthorized user", user=discord_id)
# Send pairing instructions
await message.reply(
"I don't recognize you! Please pair with me first by "
"using the pairing command in OpenClaw."
)
return
# Forward to OpenClaw
if self.on_openclaw_message:
await self._forward_message(message)
async def _forward_message(self, message: DiscordMessage) -> None:
"""Forward Discord message to OpenClaw.
Args:
message: Discord message.
"""
discord_id = str(message.author.id)
# Get or create WebSocket user ID
mapping = await self.state.get_user_mapping(discord_id)
if mapping:
ws_user_id = mapping.websocket_user_id
else:
# Use Discord ID as WebSocket user ID
ws_user_id = f"discord:{discord_id}"
await self.state.create_user_mapping(discord_id, ws_user_id)
# Handle message content
if message.content:
openclaw_msg = {
"type": "message",
"from": ws_user_id,
"content": {
"type": "text",
"text": message.content,
},
"metadata": {
"discord_id": discord_id,
"channel_id": str(message.channel.id),
"message_id": str(message.id),
},
}
self.on_openclaw_message(openclaw_msg)
# Handle attachments
for attachment in message.attachments:
if attachment.filename.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
openclaw_msg = {
"type": "message",
"from": ws_user_id,
"content": {
"type": "image",
"url": attachment.url,
"filename": attachment.filename,
},
"metadata": {
"discord_id": discord_id,
"channel_id": str(message.channel.id),
"message_id": str(message.id),
},
}
else:
openclaw_msg = {
"type": "message",
"from": ws_user_id,
"content": {
"type": "file",
"url": attachment.url,
"filename": attachment.filename,
},
"metadata": {
"discord_id": discord_id,
"channel_id": str(message.channel.id),
"message_id": str(message.id),
},
}
self.on_openclaw_message(openclaw_msg)
async def on_reaction_add(self, reaction, user) -> None:
"""Handle reaction added.
Args:
reaction: Discord reaction.
user: User who added reaction.
"""
if user.id == self.user.id:
return
discord_id = str(user.id)
mapping = await self.state.get_user_mapping(discord_id)
ws_user_id = mapping.websocket_user_id if mapping else f"discord:{discord_id}"
if self.on_openclaw_message:
openclaw_msg = {
"type": "reaction",
"from": ws_user_id,
"messageId": str(reaction.message.id),
"emoji": str(reaction.emoji),
"action": "add",
"metadata": {
"discord_id": discord_id,
"channel_id": str(reaction.message.channel.id),
},
}
self.on_openclaw_message(openclaw_msg)
async def on_reaction_remove(self, reaction, user) -> None:
"""Handle reaction removed.
Args:
reaction: Discord reaction.
user: User who removed reaction.
"""
if user.id == self.user.id:
return
discord_id = str(user.id)
mapping = await self.state.get_user_mapping(discord_id)
ws_user_id = mapping.websocket_user_id if mapping else f"discord:{discord_id}"
if self.on_openclaw_message:
openclaw_msg = {
"type": "reaction",
"from": ws_user_id,
"messageId": str(reaction.message.id),
"emoji": str(reaction.emoji),
"action": "remove",
"metadata": {
"discord_id": discord_id,
"channel_id": str(reaction.message.channel.id),
},
}
self.on_openclaw_message(openclaw_msg)
async def on_typing(self, channel, user, when) -> None:
"""Handle typing indicator.
Args:
channel: Channel where typing occurred.
user: User who is typing.
when: When typing started.
"""
if user.id == self.user.id:
return
if not isinstance(channel, discord.DMChannel):
return
if not self.config.features.enable_typing:
return
discord_id = str(user.id)
mapping = await self.state.get_user_mapping(discord_id)
ws_user_id = mapping.websocket_user_id if mapping else f"discord:{discord_id}"
if self.on_openclaw_message:
openclaw_msg = {
"type": "typing",
"from": ws_user_id,
"isTyping": True,
"metadata": {
"discord_id": discord_id,
},
}
self.on_openclaw_message(openclaw_msg)

339
src/main.py Normal file
View File

@@ -0,0 +1,339 @@
"""Main entry point for Discord-OpenClaw client."""
from __future__ import annotations
import asyncio
import signal
import sys
from typing import Any
import structlog
from .config import Config, get_config
from .database import Database
from .discord_client import DiscordClient
from .state import StateManager
from .websocket_client import WebSocketClient
def setup_logging(config: Config) -> None:
"""Configure structured logging.
Args:
config: Application configuration.
"""
log_level = config.logging.level
log_format = config.logging.format
processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
]
if log_format == "json":
processors.append(structlog.processors.JSONRenderer())
else:
processors.append(structlog.dev.ConsoleRenderer(colors=True))
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(
getattr(sys, "loglevel", log_level)
),
cache_logger_on_first_use=True,
)
if config.logging.file:
# TODO: Add file logging
pass
class Client:
"""Main client coordinating Discord and OpenClaw connections."""
def __init__(self, config: Config) -> None:
"""Initialize client.
Args:
config: Application configuration.
"""
self.config = config
self.logger = structlog.get_logger()
# Initialize components
self.db = Database(config.database.path)
self.state = StateManager(self.db, config.security.pairing_code_ttl)
self.ws_client = WebSocketClient(config, self._on_openclaw_message)
self.discord_client = DiscordClient(config, self.state, self._on_discord_message)
self._running = False
async def start(self) -> None:
"""Start the client."""
self.logger.info("Starting Discord-OpenClaw client")
# Connect to database
await self.db.connect()
self.logger.info("Database connected")
# Start WebSocket client
await self.ws_client.connect()
self.logger.info("WebSocket client started")
# Start Discord client (blocking)
self.logger.info("Starting Discord client...")
await self.discord_client.start_client()
async def stop(self) -> None:
"""Stop the client."""
self.logger.info("Stopping Discord-OpenClaw client")
self._running = False
# Disconnect Discord
await self.discord_client.close()
# Disconnect WebSocket
await self.ws_client.disconnect()
# Close database
await self.db.disconnect()
self.logger.info("Client stopped")
def _on_openclaw_message(self, message: dict[str, Any]) -> None:
"""Handle message from OpenClaw.
Args:
message: Message from OpenClaw.
"""
# Schedule async handler
asyncio.create_task(self._handle_openclaw_message(message))
async def _handle_openclaw_message(self, message: dict[str, Any]) -> None:
"""Handle message from OpenClaw asynchronously.
Args:
message: Message from OpenClaw.
"""
msg_type = message.get("type", "unknown")
self.logger.debug("Received OpenClaw message", type=msg_type)
try:
if msg_type == "message":
await self._handle_openclaw_text(message)
elif msg_type == "typing":
await self._handle_openclaw_typing(message)
elif msg_type == "reaction":
await self._handle_openclaw_reaction(message)
else:
self.logger.warning("Unknown OpenClaw message type", type=msg_type)
except Exception as e:
self.logger.error("Error handling OpenClaw message", error=str(e))
async def _handle_openclaw_text(self, message: dict[str, Any]) -> None:
"""Handle text message from OpenClaw.
Args:
message: Text message from OpenClaw.
"""
to_id = message.get("to")
if not to_id:
self.logger.warning("Message missing 'to' field")
return
# Look up Discord ID
mapping = await self.state.get_user_mapping_by_ws(to_id)
if not mapping:
# Try to parse Discord ID from WebSocket user ID
if to_id.startswith("discord:"):
discord_id = to_id[8:] # Remove "discord:" prefix
else:
self.logger.warning("No mapping found for WebSocket user", to=to_id)
return
else:
discord_id = mapping.discord_id
content = message.get("content", {})
content_type = content.get("type", "text")
if content_type == "text":
text = content.get("text", "")
if text:
await self.discord_client.send_to_user(discord_id, text)
elif content_type == "image":
url = content.get("url", "")
caption = content.get("caption")
if url:
await self.discord_client.send_image_to_user(discord_id, url, caption)
elif content_type == "file":
url = content.get("url", "")
filename = content.get("filename", "file")
if url:
await self.discord_client.send_file_to_user(discord_id, url, filename)
else:
self.logger.warning("Unknown content type", type=content_type)
async def _handle_openclaw_typing(self, message: dict[str, Any]) -> None:
"""Handle typing indicator from OpenClaw.
Args:
message: Typing message from OpenClaw.
"""
if not self.config.features.enable_typing:
return
to_id = message.get("to")
if not to_id:
return
mapping = await self.state.get_user_mapping_by_ws(to_id)
if mapping:
await self.discord_client.trigger_typing(mapping.discord_id)
async def _handle_openclaw_reaction(self, message: dict[str, Any]) -> None:
"""Handle reaction from OpenClaw.
Args:
message: Reaction message from OpenClaw.
"""
if not self.config.features.enable_reactions:
return
to_id = message.get("to")
message_id = message.get("messageId")
emoji = message.get("emoji")
action = message.get("action", "add")
if not all([to_id, message_id, emoji]):
self.logger.warning("Reaction missing required fields")
return
mapping = await self.state.get_user_mapping_by_ws(to_id)
if not mapping:
return
# For now, we can't easily add reactions to specific messages
# without the Discord channel/message ID mapping
self.logger.debug(
"Reaction received (not yet implemented)",
to=to_id,
emoji=emoji,
action=action,
)
def _on_discord_message(self, message: dict[str, Any]) -> None:
"""Handle message from Discord.
Args:
message: Message from Discord.
"""
# Schedule async handler
asyncio.create_task(self._handle_discord_message(message))
async def _handle_discord_message(self, message: dict[str, Any]) -> None:
"""Handle message from Discord asynchronously.
Args:
message: Message from Discord.
"""
msg_type = message.get("type", "unknown")
self.logger.debug("Received Discord message", type=msg_type)
try:
# Log the message
metadata = message.get("metadata", {})
discord_id = metadata.get("discord_id", "unknown")
ws_user_id = message.get("from", "unknown")
await self.state.log_message(
direction="discord_to_openclaw",
discord_id=discord_id,
websocket_user_id=ws_user_id,
message_type=msg_type,
content=str(message.get("content", "")),
)
# Send to OpenClaw
await self.ws_client.send_message(message)
except Exception as e:
self.logger.error("Error handling Discord message", error=str(e))
async def run(config_path: str | None = None) -> None:
"""Run the client.
Args:
config_path: Optional path to config file.
"""
# Load configuration
try:
config = get_config(config_path)
except Exception as e:
print(f"Failed to load configuration: {e}")
sys.exit(1)
# Setup logging
setup_logging(config)
logger = structlog.get_logger()
# Create client
client = Client(config)
# Setup signal handlers
loop = asyncio.get_event_loop()
stop_event = asyncio.Event()
def signal_handler() -> None:
logger.info("Shutdown signal received")
stop_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, signal_handler)
# Run client
try:
# Start client in background
client_task = asyncio.create_task(client.start())
# Wait for stop signal
await stop_event.wait()
# Stop client
await client.stop()
# Cancel client task
client_task.cancel()
try:
await client_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("Client error", error=str(e))
await client.stop()
sys.exit(1)
def main() -> None:
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="Discord-OpenClaw Client")
parser.add_argument(
"-c", "--config",
default="config.yaml",
help="Path to configuration file",
)
args = parser.parse_args()
asyncio.run(run(args.config))
if __name__ == "__main__":
main()

268
src/mapper.py Normal file
View File

@@ -0,0 +1,268 @@
"""Message type conversion between Discord and OpenClaw."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class TextContent:
"""Text message content."""
type: str = "text"
text: str = ""
def to_dict(self) -> dict[str, str]:
"""Convert to dictionary."""
return {"type": "text", "text": self.text}
@dataclass
class ImageContent:
"""Image message content."""
type: str = "image"
url: str = ""
caption: str | None = None
filename: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
result: dict[str, Any] = {"type": "image", "url": self.url}
if self.caption:
result["caption"] = self.caption
if self.filename:
result["filename"] = self.filename
return result
@dataclass
class FileContent:
"""File message content."""
type: str = "file"
url: str = ""
filename: str = ""
mime_type: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
result: dict[str, Any] = {
"type": "file",
"url": self.url,
"filename": self.filename,
}
if self.mime_type:
result["mimeType"] = self.mime_type
return result
@dataclass
class VoiceContent:
"""Voice message content."""
type: str = "voice"
url: str = ""
duration: float | None = None
transcript: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
result: dict[str, Any] = {"type": "voice", "url": self.url}
if self.duration:
result["duration"] = self.duration
if self.transcript:
result["transcript"] = self.transcript
return result
class MessageMapper:
"""Maps messages between Discord and OpenClaw formats."""
@staticmethod
def openclaw_to_discord(message: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Convert OpenClaw message to Discord format.
Args:
message: OpenClaw message.
Returns:
Tuple of (content, kwargs) for Discord send.
"""
content = message.get("content", {})
content_type = content.get("type", "text")
if content_type == "text":
text = content.get("text", "")
return text, {}
if content_type == "image":
# Return URL as content with optional caption
url = content.get("url", "")
caption = content.get("caption", "")
text = f"{caption}\n{url}" if caption else url
return text, {}
if content_type == "file":
# Return URL and filename
url = content.get("url", "")
filename = content.get("filename", "file")
text = f"📎 {filename}: {url}"
return text, {}
if content_type == "voice":
url = content.get("url", "")
duration = content.get("duration")
transcript = content.get("transcript", "")
text = f"🎤 Voice message"
if duration:
text += f" ({duration}s)"
if transcript:
text += f"\n📝 {transcript}"
return text, {}
# Unknown type, return as-is
return str(content), {}
@staticmethod
def discord_to_openclaw(
discord_id: str,
content: str,
channel_id: str,
message_id: str,
attachments: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Convert Discord message to OpenClaw format.
Args:
discord_id: Discord user ID.
content: Message content.
channel_id: Discord channel ID.
message_id: Discord message ID.
attachments: List of attachments.
Returns:
List of OpenClaw messages.
"""
messages = []
ws_user_id = f"discord:{discord_id}"
metadata = {
"discord_id": discord_id,
"channel_id": channel_id,
"message_id": message_id,
}
# Text message
if content:
messages.append({
"type": "message",
"from": ws_user_id,
"content": {"type": "text", "text": content},
"metadata": metadata,
})
# Attachments
if attachments:
for attachment in attachments:
filename = attachment.get("filename", "file")
url = attachment.get("url", "")
mime_type = attachment.get("content_type", "")
# Check if image
if any(
ext in filename.lower()
for ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]
):
messages.append({
"type": "message",
"from": ws_user_id,
"content": {
"type": "image",
"url": url,
"filename": filename,
},
"metadata": metadata,
})
# Check if voice/audio
elif mime_type.startswith("audio/") or "voice" in filename.lower():
messages.append({
"type": "message",
"from": ws_user_id,
"content": {
"type": "voice",
"url": url,
"filename": filename,
},
"metadata": metadata,
})
else:
# Regular file
messages.append({
"type": "message",
"from": ws_user_id,
"content": {
"type": "file",
"url": url,
"filename": filename,
"mimeType": mime_type or None,
},
"metadata": metadata,
})
return messages
@staticmethod
def reaction_to_openclaw(
discord_id: str,
message_id: str,
emoji: str,
action: str,
channel_id: str,
) -> dict[str, Any]:
"""Convert Discord reaction to OpenClaw format.
Args:
discord_id: Discord user ID.
message_id: Message ID.
emoji: Reaction emoji.
action: 'add' or 'remove'.
channel_id: Discord channel ID.
Returns:
OpenClaw reaction message.
"""
return {
"type": "reaction",
"from": f"discord:{discord_id}",
"messageId": message_id,
"emoji": emoji,
"action": action,
"metadata": {
"discord_id": discord_id,
"channel_id": channel_id,
},
}
@staticmethod
def typing_to_openclaw(
discord_id: str,
is_typing: bool,
) -> dict[str, Any]:
"""Convert Discord typing to OpenClaw format.
Args:
discord_id: Discord user ID.
is_typing: Whether user is typing.
Returns:
OpenClaw typing message.
"""
return {
"type": "typing",
"from": f"discord:{discord_id}",
"isTyping": is_typing,
"metadata": {
"discord_id": discord_id,
},
}

210
src/state.py Normal file
View File

@@ -0,0 +1,210 @@
"""State management for Discord-OpenClaw client."""
from __future__ import annotations
import secrets
from dataclasses import dataclass, field
from typing import Any
from .database import Database
@dataclass
class UserMapping:
"""Represents a mapping between Discord and WebSocket users."""
discord_id: str
websocket_user_id: str
@dataclass
class PairingCode:
"""Represents a pairing code for DM verification."""
code: str
discord_id: str
websocket_user_id: str
expires_at: str
used: bool = False
@dataclass
class StateManager:
"""Manages application state with database persistence."""
db: Database
_pairing_code_ttl: int = 300
async def get_user_mapping(self, discord_id: str) -> UserMapping | None:
"""Get user mapping by Discord ID.
Args:
discord_id: Discord user ID.
Returns:
UserMapping if found, None otherwise.
"""
data = await self.db.get_user_mapping(discord_id)
if data:
return UserMapping(
discord_id=data["discord_id"],
websocket_user_id=data["websocket_user_id"],
)
return None
async def get_user_mapping_by_ws(self, websocket_user_id: str) -> UserMapping | None:
"""Get user mapping by WebSocket user ID.
Args:
websocket_user_id: WebSocket user ID.
Returns:
UserMapping if found, None otherwise.
"""
data = await self.db.get_user_mapping_by_ws(websocket_user_id)
if data:
return UserMapping(
discord_id=data["discord_id"],
websocket_user_id=data["websocket_user_id"],
)
return None
async def create_user_mapping(self, discord_id: str, websocket_user_id: str) -> None:
"""Create or update user mapping.
Args:
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
"""
await self.db.create_user_mapping(discord_id, websocket_user_id)
async def generate_pairing_code(self, discord_id: str, websocket_user_id: str) -> str:
"""Generate a new pairing code.
Args:
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
Returns:
Generated pairing code.
"""
code = secrets.token_urlsafe(8).upper()[:8]
await self.db.create_pairing_code(
code, discord_id, websocket_user_id, self._pairing_code_ttl
)
return code
async def verify_pairing_code(self, code: str) -> PairingCode | None:
"""Verify and consume a pairing code.
Args:
code: Pairing code to verify.
Returns:
PairingCode if valid, None otherwise.
"""
data = await self.db.get_pairing_code(code)
if not data:
return None
# Mark as used
if not await self.db.use_pairing_code(code):
return None
return PairingCode(
code=data["code"],
discord_id=data["discord_id"],
websocket_user_id=data["websocket_user_id"],
expires_at=data["expires_at"],
used=True,
)
async def is_user_allowed(self, discord_id: str, security_mode: str) -> bool:
"""Check if user is allowed to interact.
Args:
discord_id: Discord user ID.
security_mode: Security mode ('open', 'allowlist', 'paired').
Returns:
True if user is allowed.
"""
if security_mode == "open":
return True
if security_mode == "allowlist":
return await self.db.is_allowlisted(discord_id)
if security_mode == "paired":
mapping = await self.get_user_mapping(discord_id)
return mapping is not None
return False
async def add_to_allowlist(
self, discord_id: str, websocket_user_id: str, notes: str | None = None
) -> None:
"""Add user to allowlist.
Args:
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
notes: Optional notes.
"""
await self.db.add_to_allowlist(discord_id, websocket_user_id, notes)
# Also create mapping
await self.create_user_mapping(discord_id, websocket_user_id)
async def remove_from_allowlist(self, discord_id: str) -> bool:
"""Remove user from allowlist.
Args:
discord_id: Discord user ID.
Returns:
True if user was removed.
"""
return await self.db.remove_from_allowlist(discord_id)
async def get_state(self, key: str) -> str | None:
"""Get connection state value.
Args:
key: State key.
Returns:
State value or None.
"""
return await self.db.get_state(key)
async def set_state(self, key: str, value: str) -> None:
"""Set connection state value.
Args:
key: State key.
value: State value.
"""
await self.db.set_state(key, value)
async def log_message(
self,
direction: str,
discord_id: str | None,
websocket_user_id: str | None,
message_type: str,
content: str | None,
) -> None:
"""Log message for debugging.
Args:
direction: Message direction.
discord_id: Discord user ID.
websocket_user_id: WebSocket user ID.
message_type: Message type.
content: Message content.
"""
await self.db.log_message(direction, discord_id, websocket_user_id, message_type, content)
async def cleanup(self) -> None:
"""Clean up expired data."""
await self.db.cleanup_expired_codes()

473
src/websocket_client.py Normal file
View File

@@ -0,0 +1,473 @@
"""OpenClaw WebSocket client implementation."""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable
import structlog
import websockets
from websockets.client import WebSocketClientProtocol
from .config import Config
logger = structlog.get_logger()
class ConnectionState(Enum):
"""WebSocket connection state."""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
AUTHENTICATED = "authenticated"
READY = "ready"
@dataclass
class ServerCapabilities:
"""Server capabilities from handshake."""
text: bool = False
images: bool = False
files: bool = False
voice: bool = False
reactions: bool = False
threads: bool = False
typing: bool = False
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ServerCapabilities:
"""Create from dictionary."""
return cls(
text=data.get("text", False),
images=data.get("images", False),
files=data.get("files", False),
voice=data.get("voice", False),
reactions=data.get("reactions", False),
threads=data.get("threads", False),
typing=data.get("typing", False),
)
@dataclass
class ClientCapabilities:
"""Client capabilities to report."""
text: bool = True
images: bool = True
files: bool = True
voice: bool = True
reactions: bool = True
threads: bool = True
typing: bool = True
def to_dict(self) -> dict[str, bool]:
"""Convert to dictionary."""
return {
"text": self.text,
"images": self.images,
"files": self.files,
"voice": self.voice,
"reactions": self.reactions,
"threads": self.threads,
"typing": self.typing,
}
class WebSocketClient:
"""OpenClaw WebSocket client."""
def __init__(
self,
config: Config,
on_message: Callable[[dict[str, Any]], None] | None = None,
) -> None:
"""Initialize WebSocket client.
Args:
config: Application configuration.
on_message: Callback for incoming messages.
"""
self.config = config
self.on_message = on_message
self._ws: WebSocketClientProtocol | None = None
self._state = ConnectionState.DISCONNECTED
self._reconnect_attempts = 0
self._task: asyncio.Task | None = None
self._server_capabilities: ServerCapabilities | None = None
self._account_id: str | None = None
self._running = False
# Message handlers
self._handlers: dict[str, Callable[[dict[str, Any]], None]] = {
"handshake": self._handle_handshake,
"request_capabilities": self._handle_request_capabilities,
"message": self._handle_message,
"typing": self._handle_typing,
"reaction": self._handle_reaction,
"ping": self._handle_ping,
}
@property
def state(self) -> ConnectionState:
"""Get current connection state."""
return self._state
@property
def server_capabilities(self) -> ServerCapabilities | None:
"""Get server capabilities."""
return self._server_capabilities
@property
def is_ready(self) -> bool:
"""Check if client is ready."""
return self._state == ConnectionState.READY
async def connect(self) -> None:
"""Connect to OpenClaw WebSocket server."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run())
async def disconnect(self) -> None:
"""Disconnect from server."""
self._running = False
if self._ws:
await self._ws.close()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._state = ConnectionState.DISCONNECTED
async def send_message(self, message: dict[str, Any]) -> bool:
"""Send message to OpenClaw.
Args:
message: Message to send.
Returns:
True if sent successfully.
"""
if not self._ws or self._state != ConnectionState.READY:
logger.warning("Cannot send message: not connected")
return False
try:
await self._ws.send(json.dumps(message))
logger.debug("Sent message", type=message.get("type"))
return True
except Exception as e:
logger.error("Failed to send message", error=str(e))
return False
async def send_text(
self,
from_id: str,
text: str,
reply_to: str | None = None,
) -> bool:
"""Send text message to OpenClaw.
Args:
from_id: Sender Discord ID.
text: Message text.
reply_to: Optional message ID to reply to.
Returns:
True if sent successfully.
"""
message: dict[str, Any] = {
"type": "message",
"from": from_id,
"content": {
"type": "text",
"text": text,
},
}
if reply_to:
message["replyTo"] = reply_to
return await self.send_message(message)
async def send_image(
self,
from_id: str,
url: str,
caption: str | None = None,
) -> bool:
"""Send image message to OpenClaw.
Args:
from_id: Sender Discord ID.
url: Image URL.
caption: Optional caption.
Returns:
True if sent successfully.
"""
content: dict[str, Any] = {
"type": "image",
"url": url,
}
if caption:
content["caption"] = caption
return await self.send_message({
"type": "message",
"from": from_id,
"content": content,
})
async def send_file(
self,
from_id: str,
url: str,
filename: str,
mime_type: str | None = None,
) -> bool:
"""Send file message to OpenClaw.
Args:
from_id: Sender Discord ID.
url: File URL.
filename: File name.
mime_type: Optional MIME type.
Returns:
True if sent successfully.
"""
content: dict[str, Any] = {
"type": "file",
"url": url,
"filename": filename,
}
if mime_type:
content["mimeType"] = mime_type
return await self.send_message({
"type": "message",
"from": from_id,
"content": content,
})
async def send_reaction(
self,
from_id: str,
message_id: str,
emoji: str,
action: str = "add",
) -> bool:
"""Send reaction to OpenClaw.
Args:
from_id: Sender Discord ID.
message_id: Message ID.
emoji: Reaction emoji.
action: 'add' or 'remove'.
Returns:
True if sent successfully.
"""
return await self.send_message({
"type": "reaction",
"from": from_id,
"messageId": message_id,
"emoji": emoji,
"action": action,
})
async def send_typing(self, from_id: str, is_typing: bool = True) -> bool:
"""Send typing indicator to OpenClaw.
Args:
from_id: Sender Discord ID.
is_typing: Whether user is typing.
Returns:
True if sent successfully.
"""
return await self.send_message({
"type": "typing",
"from": from_id,
"isTyping": is_typing,
})
async def _run(self) -> None:
"""Main run loop with reconnection."""
while self._running:
try:
await self._connect_and_listen()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Connection error", error=str(e))
if self._running:
await self._reconnect()
async def _connect_and_listen(self) -> None:
"""Connect and listen for messages."""
self._state = ConnectionState.CONNECTING
# Build URL with API key
url = self.config.openclaw.websocket_url
api_key = self.config.openclaw.api_key.get_secret_value()
separator = "&" if "?" in url else "?"
ws_url = f"{url}{separator}key={api_key}"
logger.info("Connecting to OpenClaw", url=self.config.openclaw.websocket_url)
try:
async with websockets.connect(
ws_url,
ping_interval=self.config.openclaw.ping_interval,
ping_timeout=10,
) as ws:
self._ws = ws
self._state = ConnectionState.CONNECTED
self._reconnect_attempts = 0
logger.info("Connected to OpenClaw WebSocket")
# Listen for messages
async for raw_message in ws:
try:
message = json.loads(raw_message)
await self._process_message(message)
except json.JSONDecodeError as e:
logger.warning("Invalid JSON message", error=str(e))
except Exception as e:
logger.error("Error processing message", error=str(e))
except websockets.exceptions.ConnectionClosed as e:
logger.warning("Connection closed", code=e.code, reason=str(e.reason))
except Exception as e:
logger.error("Connection error", error=str(e))
finally:
self._ws = None
self._state = ConnectionState.DISCONNECTED
async def _reconnect(self) -> None:
"""Handle reconnection with exponential backoff."""
self._reconnect_attempts += 1
# Calculate delay with exponential backoff
delay = min(
self.config.openclaw.reconnect_delay * (2 ** (self._reconnect_attempts - 1)),
self.config.openclaw.max_reconnect_delay,
)
logger.info(
"Reconnecting",
attempt=self._reconnect_attempts,
delay=delay,
)
await asyncio.sleep(delay)
async def _process_message(self, message: dict[str, Any]) -> None:
"""Process incoming message.
Args:
message: Received message.
"""
msg_type = message.get("type", "unknown")
logger.debug("Received message", type=msg_type)
handler = self._handlers.get(msg_type)
if handler:
await handler(message)
else:
logger.warning("Unknown message type", type=msg_type)
async def _handle_handshake(self, message: dict[str, Any]) -> None:
"""Handle handshake message.
Args:
message: Handshake message.
"""
self._account_id = message.get("accountId", "default")
capabilities = message.get("capabilities", {})
self._server_capabilities = ServerCapabilities.from_dict(capabilities)
self._state = ConnectionState.AUTHENTICATED
logger.info(
"Handshake received",
account_id=self._account_id,
capabilities=capabilities,
)
async def _handle_request_capabilities(self, message: dict[str, Any]) -> None:
"""Handle capabilities request.
Args:
message: Request message.
"""
# Send capabilities response
capabilities = ClientCapabilities(
text=self.config.features.enable_reactions, # Text is always enabled
images=True,
files=True,
voice=self.config.features.enable_voice,
reactions=self.config.features.enable_reactions,
threads=self.config.features.enable_threads,
typing=self.config.features.enable_typing,
)
response = {
"type": "capabilities_response",
"capabilities": capabilities.to_dict(),
"metadata": {
"clientName": "Discord User Client",
"version": "1.0.0",
"platform": "discord",
},
}
await self.send_message(response)
self._state = ConnectionState.READY
logger.info("Capabilities sent, client ready")
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle incoming message from OpenClaw.
Args:
message: Message from OpenClaw.
"""
if self.on_message:
self.on_message(message)
else:
logger.debug("No message handler set")
async def _handle_typing(self, message: dict[str, Any]) -> None:
"""Handle typing indicator.
Args:
message: Typing message.
"""
if self.on_message:
self.on_message(message)
async def _handle_reaction(self, message: dict[str, Any]) -> None:
"""Handle reaction.
Args:
message: Reaction message.
"""
if self.on_message:
self.on_message(message)
async def _handle_ping(self, message: dict[str, Any]) -> None:
"""Handle ping message.
Args:
message: Ping message.
"""
# Send pong response
await self.send_message({"type": "pong"})

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"