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:
15
.env.example
Normal file
15
.env.example
Normal 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
73
.gitignore
vendored
Normal 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
262
README.md
Normal 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
58
config.example.yaml
Normal 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
60
pyproject.toml
Normal 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
4
src/__init__.py
Normal 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
154
src/config.py
Normal 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
387
src/database.py
Normal 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
477
src/discord_client.py
Normal 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
339
src/main.py
Normal 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
268
src/mapper.py
Normal 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
210
src/state.py
Normal 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
473
src/websocket_client.py
Normal 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
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