diff --git a/.prek-config.yaml b/.prek-config.yaml deleted file mode 100644 index 9ca2491e..00000000 --- a/.prek-config.yaml +++ /dev/null @@ -1,102 +0,0 @@ -default_language_version: - python: "3.12" -default_install_hook_types: [commit-msg, pre-commit] -repos: - - repo: https://github.com/compilerla/conventional-pre-commit - rev: v4.3.0 - hooks: - - id: conventional-pre-commit - stages: [commit-msg] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: check-ast - - id: check-case-conflict - - id: check-toml - - id: debug-statements - - id: end-of-file-fixer - - id: mixed-line-ending - - id: trailing-whitespace - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.14.6" - hooks: - - id: ruff-check - args: ["--fix"] - - id: ruff-format - - repo: https://github.com/sourcery-ai/sourcery - rev: v1.40.0 - hooks: - - id: sourcery - args: [--diff=git diff HEAD, --no-summary] - stages: [commit] - - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 - hooks: - - id: codespell - additional_dependencies: - - tomli - exclude: > - (?x)^( - .*\.json |.*\.svg |docs/changelog.rst - )$ - - repo: https://github.com/asottile/blacken-docs - rev: 1.20.0 - hooks: - - id: blacken-docs - - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v4.0.0-alpha.8" - hooks: - - id: prettier - exclude: "_templates|.git" - # - repo: https://github.com/thibaudcolas/curlylint - # rev: v0.13.1 - # hooks: - # - id: curlylint - # args: ["--format", "stylish"] - - repo: https://github.com/python-formate/flake8-dunder-all - rev: v0.5.0 - hooks: - - id: ensure-dunder-all - exclude: "test*|examples*|tools" - args: ["--use-tuple"] - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: "v1.6.1" - # hooks: - # - id: mypy - # exclude: "tools|docs" - # additional_dependencies: - # [ - # annotated_types, - # httpx, - # httpx_sse, - # hypothesis, - # jsbeautifier, - # pydantic>=2, - # pydantic-extra-types, - # pytest, - # pytest-lazy-fixture, - # pytest-mock, - # pytest_docker, - # python-dotenv, - # click, - # rich, - # rich-click, - # structlog, - # uvicorn, - # prometheus_client, - # litestar, - # polyfactory, - # discord-py, - # ] - - repo: local - hooks: - - id: ty - name: ty - entry: uvx ty check - language: system - types: [python] - pass_filenames: false - - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: "v1.0.2" - hooks: - - id: sphinx-lint diff --git a/Makefile b/Makefile index e93c7192..eecc6f77 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ install-backend: ## Install the backend dependencies @echo "=> Backend dependencies installed" .PHONY: install -install: clean destroy ## Install the project, dependencies, and pre-commit for local development +install: clean destroy ## Install the project, dependencies, and prek for local development @if ! $(UV) --version > /dev/null; then $(MAKE) install-uv; fi @$(MAKE) install-backend @$(MAKE) install-frontend diff --git a/services/bot/Dockerfile b/services/bot/Dockerfile new file mode 100644 index 00000000..6af1451a --- /dev/null +++ b/services/bot/Dockerfile @@ -0,0 +1,69 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================ +# Builder Stage: Install dependencies and build virtual environment +# ============================================================================ +FROM python:3.12-slim AS builder + +# Set working directory +WORKDIR /app + +# Install system dependencies required for building Python packages +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + make \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv binary from official image +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set uv environment variables for optimal performance +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +# Copy workspace configuration files +COPY pyproject.toml uv.lock ./ + +# Copy shared packages and service code +COPY packages/byte-common ./packages/byte-common +COPY services/bot ./services/bot + +# Install dependencies with frozen lockfile (no dev dependencies) +# This creates a .venv directory with all dependencies +RUN uv sync --frozen --no-dev --no-editable + +# ============================================================================ +# Runtime Stage: Minimal production image +# ============================================================================ +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Create non-root user for security +RUN groupadd -r botuser && \ + useradd -r -g botuser -u 1000 -d /app -s /sbin/nologin botuser + +# Copy virtual environment from builder stage +COPY --from=builder --chown=botuser:botuser /app/.venv /app/.venv + +# Copy application code from builder stage +COPY --from=builder --chown=botuser:botuser /app/services/bot /app/services/bot +COPY --from=builder --chown=botuser:botuser /app/packages/byte-common /app/packages/byte-common + +# Copy workspace configuration (needed for uv run commands) +COPY --from=builder --chown=botuser:botuser /app/pyproject.toml /app/uv.lock ./ + +# Set PATH to use virtual environment binaries +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH="/app" + +# Switch to non-root user +USER botuser + +# Run the Discord bot +CMD ["python", "-m", "byte_bot"] diff --git a/services/bot/README.md b/services/bot/README.md index 7f43bf0a..b7763051 100644 --- a/services/bot/README.md +++ b/services/bot/README.md @@ -6,34 +6,228 @@ Discord bot service for the Byte Bot application. This service handles all Discord-related functionality including: -- Discord bot event handling +- Discord bot event handling (on_message, on_guild_join, etc.) - Slash commands and interactions -- Discord.py integration -- Bot-specific plugins and cogs +- Discord.py plugin system (cogs) +- Discord UI components (views, modals, buttons) +- Integration with Byte API for database operations ## Technology Stack +- **Python**: 3.12+ +- **Discord.py**: v2.3.2+ +- **HTTP Client**: httpx (for API calls) +- **Settings**: pydantic-settings +- **Shared Code**: `byte-common` package (workspace dependency) + +## Project Structure + +``` +services/bot/ +├── src/ +│ └── byte_bot/ # Bot source code +│ ├── __init__.py +│ ├── __main__.py # Entry point +│ ├── bot.py # Bot class and runner +│ ├── api_client.py # HTTP client for Byte API +│ ├── config.py # Bot settings +│ ├── lib/ # Bot infrastructure +│ │ ├── checks.py # Permission checks +│ │ ├── log.py # Logging configuration +│ │ ├── utils.py # Utilities +│ │ ├── common/ # Common resources (links, messages) +│ │ └── types/ # Type definitions (Astral, Python) +│ ├── plugins/ # Discord commands (auto-loaded) +│ │ ├── admin.py # Admin commands +│ │ ├── config.py # Configuration commands +│ │ ├── events.py # Event handlers +│ │ ├── forums.py # Forum management +│ │ ├── github.py # GitHub integration +│ │ ├── python.py # Python documentation lookups +│ │ ├── astral.py # Astral project lookups +│ │ ├── general.py # General commands +│ │ └── custom/ # Custom plugins (Litestar, etc.) +│ └── views/ # Discord UI components +│ ├── astral.py # Astral project views +│ ├── config.py # Configuration modals +│ ├── forums.py # Forum management views +│ └── python.py # Python documentation views +├── tests/ # Bot-specific tests (TODO) +├── pyproject.toml # Package configuration +├── Dockerfile # Production container +└── README.md # This file +``` + +## Development + +### Prerequisites + - Python 3.12+ -- Discord.py v2 -- Shared code from `byte-common` package +- uv package manager +- Discord bot token (from Discord Developer Portal) +- Access to Byte API (or run locally) -## Status +### Setup -**Phase 0.1**: Directory structure created. Implementation pending. +From the **workspace root** (`/byte`): -## Future Structure +```bash +# Install all dependencies (including bot service) +uv sync +# Set up environment variables +cp .env.example .env +# Edit .env and set: +# - DISCORD_TOKEN +# - DISCORD_DEV_GUILD_ID +# - DISCORD_DEV_USER_ID +# - API_URL (defaults to http://localhost:8000) ``` -services/bot/ -├── byte_bot/ # Bot source code -│ ├── plugins/ # Discord plugins/cogs -│ ├── lib/ # Bot-specific utilities -│ └── views/ # Discord UI components -├── tests/ # Bot-specific tests -├── pyproject.toml # Bot service configuration -└── Dockerfile # Bot service container + +### Running Locally + +**Option 1: Via workspace command (recommended)** + +```bash +# From workspace root +uv run app run-bot ``` -## Development +**Option 2: Directly via module** + +```bash +# From workspace root +uv run python -m byte_bot +``` + +**Option 3: Via entry point** + +```bash +# From workspace root +uv run byte-bot +``` + +### Configuration + +The bot service uses environment variables for configuration. See `src/byte_bot/config.py` for all available settings: + +**Required:** + +- `DISCORD_TOKEN` - Bot token from Discord Developer Portal + +**Optional:** + +- `DISCORD_COMMAND_PREFIX` - Command prefix (default: `!`) +- `DISCORD_DEV_GUILD_ID` - Guild ID for slash command sync +- `DISCORD_DEV_USER_ID` - Your Discord user ID +- `DISCORD_PLUGINS_LOC` - Plugin module path (default: `byte_bot.plugins`) +- `API_URL` - Byte API base URL (default: `http://localhost:8000`) +- `LOG_LEVEL` - Logging level (default: `INFO`) +- `LOG_FILE` - Log file path (optional) + +### Plugin Development + +Plugins are automatically loaded from `byte_bot/plugins/`. To create a new plugin: + +```python +# src/byte_bot/plugins/my_plugin.py +from discord.ext import commands +from discord import ApplicationContext + + +class MyPlugin(commands.Cog): + """My custom plugin.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.slash_command(description="My command") + async def my_command(self, ctx: ApplicationContext): + """Handle command.""" + await ctx.respond("Hello!") + + +def setup(bot: commands.Bot): + """Required for auto-loading.""" + bot.add_cog(MyPlugin(bot)) +``` + +The plugin will be automatically loaded when the bot starts. + +## Docker + +### Building + +From the **workspace root**: + +```bash +docker build -f services/bot/Dockerfile -t byte-bot . +``` + +### Running + +```bash +docker run -d \ + --name byte-bot \ + -e DISCORD_TOKEN=your_token_here \ + -e API_URL=http://api:8000 \ + byte-bot +``` + +### Docker Compose + +```yaml +services: + bot: + build: + context: . + dockerfile: services/bot/Dockerfile + environment: + - DISCORD_TOKEN=${DISCORD_TOKEN} + - API_URL=http://api:8000 + depends_on: + - api + restart: unless-stopped +``` + +## API Integration + +The bot service calls the Byte API for database operations. See `src/byte_bot/api_client.py` for available methods: + +- `get_guild(guild_id: int)` - Fetch guild configuration +- `create_guild(guild_data: dict)` - Create new guild +- `update_guild(guild_id: str, guild_data: dict)` - Update guild +- `delete_guild(guild_id: str)` - Delete guild + +All database operations go through the API - the bot does not directly access the database. + +## Testing + +```bash +# Run bot service tests (from workspace root) +uv run pytest services/bot/tests +``` + +## Status + +**Phase 1.2**: ✅ Complete + +- [x] Bot service extracted from monolith +- [x] All plugins migrated to `services/bot/src/byte_bot/plugins/` +- [x] All views migrated to `services/bot/src/byte_bot/views/` +- [x] API client implemented +- [x] Configuration system set up +- [x] Entry points configured +- [x] Import errors fixed +- [x] Dockerfile created +- [ ] Tests implemented (TODO: Phase 1.3) + +## Next Steps + +- **Phase 1.3**: Implement comprehensive test suite +- **Phase 2**: Remove old monolith code (`byte_bot/byte/`) +- **Phase 3**: Deploy bot and API services independently + +## License -To be populated during Phase 0.2 (Bot Service Migration). +MIT License - See workspace LICENSE file diff --git a/services/bot/src/byte_bot/__metadata__.py b/services/bot/src/byte_bot/__metadata__.py new file mode 100644 index 00000000..e965ddc7 --- /dev/null +++ b/services/bot/src/byte_bot/__metadata__.py @@ -0,0 +1,6 @@ +"""Byte Bot Service metadata.""" + +__all__ = ["__project__", "__version__"] + +__version__ = "0.2.0" +__project__ = "byte-bot-service" diff --git a/services/bot/src/byte_bot/lib/settings.py b/services/bot/src/byte_bot/lib/settings.py new file mode 100644 index 00000000..7d74766e --- /dev/null +++ b/services/bot/src/byte_bot/lib/settings.py @@ -0,0 +1,167 @@ +"""Project Settings.""" + +from __future__ import annotations + +import os +from pathlib import Path # noqa: TC003 +from typing import Final + +from dotenv import load_dotenv +from litestar.utils.module_loader import module_to_os_path +from pydantic import ValidationError, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from byte_bot.__metadata__ import __version__ as version + +__all__ = [ + "DiscordSettings", + "LogSettings", + "ProjectSettings", + "discord", + "log", + "project", +] + +load_dotenv() + +DEFAULT_MODULE_NAME: str = "byte_bot" +BASE_DIR: Final = module_to_os_path(DEFAULT_MODULE_NAME) +PLUGINS_DIR: Final = module_to_os_path("byte_bot.byte.plugins") + + +class DiscordSettings(BaseSettings): + """Discord Settings.""" + + model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", env_prefix="DISCORD_", extra="ignore") + + TOKEN: str + """Discord API token.""" + COMMAND_PREFIX: list[str] = ["!"] + """Command prefix for bot commands.""" + DEV_GUILD_ID: int + """Discord Guild ID for development.""" + DEV_USER_ID: int + """Discord User ID for development.""" + DEV_GUILD_INTERNAL_ID: int = 1136100160510902272 + """Internal channel ID for the development guild.""" + PLUGINS_LOC: Path = PLUGINS_DIR + """Base Path to plugins directory.""" + PLUGINS_DIRS: list[Path] = [PLUGINS_DIR] + """Directories to search for plugins.""" + PRESENCE_URL: str = "" + + @field_validator("COMMAND_PREFIX") + @classmethod + def assemble_command_prefix(cls, value: list[str]) -> list[str]: + """Assembles the bot command prefix based on the environment. + + Args: + value: Default value of ``COMMAND_PREFIX``. Currently ``["!"]`` + + Returns: + The assembled prefix string. + """ + env_urls = { + "prod": "byte ", + "test": "bit ", + "dev": "nibble ", + } + environment = os.getenv("ENVIRONMENT", "dev") + # Add env specific command prefix in addition to the default "!" + env_prefix = os.getenv("COMMAND_PREFIX", env_urls[environment]) + if env_prefix not in value: + value.append(env_prefix) + return value + + @field_validator("PRESENCE_URL") + @classmethod + def assemble_presence_url(cls, value: str) -> str: # noqa: ARG003 + """Assembles the bot presence url based on the environment. + + Args: + value: Not used. + + Returns: + The assembled prefix string. + """ + env_urls = { + "prod": "https://byte-bot.app/", + "test": "https://dev.byte-bot.app/", + "dev": "https://dev.byte-bot.app/", + } + environment = os.getenv("ENVIRONMENT", "dev") + return os.getenv("PRESENCE_URL", env_urls[environment]) + + +class LogSettings(BaseSettings): + """Logging config for the Project.""" + + model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", env_prefix="LOG_", extra="ignore") + + LEVEL: int = 20 + """Stdlib log levels. + + Only emit logs at this level, or higher. + """ + DISCORD_LEVEL: int = 30 + """Sets the log level for the discord.py library.""" + WEBSOCKETS_LEVEL: int = 30 + """Sets the log level for the websockets library.""" + ASYNCIO_LEVEL: int = 20 + """Sets the log level for the asyncio library.""" + HTTP_CORE_LEVEL: int = 20 + """Sets the log level for the httpcore library. (Used in cert. validation)""" + HTTPX_LEVEL: int = 30 + """Sets the log level for the httpx library.""" + FORMAT: str = "[[ %(asctime)s ]] - [[ %(name)s ]] - [[ %(levelname)s ]] - %(message)s" + """Log format string.""" + FILE: Path = BASE_DIR / "logs" / "byte.log" + """Log file path.""" + + +class ProjectSettings(BaseSettings): + """Project Settings.""" + + model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", extra="ignore") + + DEBUG: bool = False + """Run app with ``debug=True``.""" + ENVIRONMENT: str = "prod" + """``dev``, ``prod``, ``test``, etc.""" + VERSION: str = version + """The current version of the application.""" + + +# noinspection PyShadowingNames +def load_settings() -> tuple[ + DiscordSettings, + LogSettings, + ProjectSettings, +]: + """Load Settings file. + + Returns: + Settings: application settings + """ + try: + """Override Application reload dir.""" + + discord: DiscordSettings = DiscordSettings.model_validate({}) + log: LogSettings = LogSettings.model_validate({}) + project: ProjectSettings = ProjectSettings.model_validate({}) + + except ValidationError as error: + print(f"Could not load settings. Error: {error!r}") # noqa: T201 + raise error from error + return ( + discord, + log, + project, + ) + + +( + discord, + log, + project, +) = load_settings()