Skip to content

feat: Phase 1.2 - Extract Bot Service from Monolith#111

Merged
JacobCoffee merged 18 commits intomainfrom
feature/phase1-extract-api-service
Nov 23, 2025
Merged

feat: Phase 1.2 - Extract Bot Service from Monolith#111
JacobCoffee merged 18 commits intomainfrom
feature/phase1-extract-api-service

Conversation

@JacobCoffee
Copy link
Owner

@JacobCoffee JacobCoffee commented Nov 23, 2025

Summary

Completes Phase 1.2 of the service extraction plan by separating the Discord bot into a standalone service (services/bot/) with its own package, dependencies, and Docker configuration.

This PR builds on:

Changes

Service Structure

  • Created services/bot/ with src/byte_bot/ layout
  • Migrated all Discord bot code from byte_bot/byte/:
    • bot.py - Bot class and runner
    • plugins/ - All Discord commands (admin, config, events, forums, github, python, astral, general, custom)
    • views/ - All Discord UI components (config, forums, python, astral)
    • lib/ - Bot infrastructure (checks, logging, settings, utilities)

Configuration

  • Created services/bot/pyproject.toml with:
    • Package name: byte-bot-service
    • Entry point: byte-bot command
    • Workspace dependency on byte-common
    • Discord.py and httpx dependencies
  • Created services/bot/src/byte_bot/config.py for bot settings
  • Created services/bot/src/byte_bot/__metadata__.py with version info

API Integration

  • Created services/bot/src/byte_bot/api_client.py for HTTP calls to Byte API
  • Bot now calls API for all database operations (no direct DB access)
  • Supports guild CRUD: get, create, update, delete

Entry Points

  • services/bot/src/byte_bot/__main__.py - Main entry point
  • Accessible via:
    • uv run app run-bot (workspace command)
    • uv run python -m byte_bot
    • uv run byte-bot (entry point script)

Docker

  • Created services/bot/Dockerfile with multi-stage build:
    • Builder stage: Install dependencies with uv
    • Runtime stage: Minimal Python 3.12 image
    • Runs as non-root user (botuser)
    • No database dependencies (uses API instead)

Documentation

  • Comprehensive services/bot/README.md with:
    • Architecture overview
    • Development setup instructions
    • Docker build and run instructions
    • Plugin development guide
    • API integration details

Import Fixes (from PR #109)

  • Fixed 172 import errors across all bot service files
  • Updated all imports to use new byte_bot package paths
  • Added ty configuration to exclude old monolith code
  • Fixed 8 Sourcery AI review comments:
    • Plugin reload path consistency
    • Permission checks in forum views
    • Ephemeral parameter handling
    • Thread import (discord vs threading)
    • anyio error handling
    • Makefile help text
    • Log configuration simplification

Type Checking

  • Configured ty to exclude old monolith paths and API service
  • All new bot service code passes ty validation
  • Created [tool.ty.src] exclude configuration

Migration from Pre-commit to Prek

  • Restored .pre-commit-config.yaml (prek still uses this naming)
  • Updated Makefile to reference prek
  • All hooks passing

Testing

  • ✅ All 75 existing tests passing
  • make ci passing (lint + type-check + format + test)
  • ✅ ty check passing with exclusions
  • ✅ ruff check and format passing
  • ✅ All pre-commit hooks passing

Deployment Impact

  • Bot service can now be deployed independently
  • API service remains unchanged (already extracted in PR feat: Phase 1.1 - Extract API Service #107)
  • Database accessed only via API (no direct connections from bot)
  • Backward compatible: Old monolith code (byte_bot/byte/) still exists but is excluded from type checking

Next Steps (Future PRs)

Phase 1.3: Implement comprehensive test suite for bot service
Phase 2: Remove old monolith code (byte_bot/byte/, byte_bot/server/)
Phase 3: Deploy services independently on Railway/Docker


🤖 Generated with Claude Code

Summary by Sourcery

Extract the Discord bot into a standalone service with its own configuration, container image, and documentation as part of the service decomposition effort.

New Features:

  • Introduce a dedicated Discord bot service package with its own project metadata and settings configuration.
  • Add an API client layer so the bot communicates with the Byte API for data access instead of direct database connections.
  • Provide a production-ready Docker image definition for the bot service using a multi-stage build.

Enhancements:

  • Greatly expand the bot service README with architecture, project structure, local development, plugin development, and deployment guidance.

Build:

  • Add a bot service Dockerfile with a multi-stage build and non-root runtime user.
  • Update the main install Makefile target description to reference prek instead of pre-commit.

Deployment:

  • Enable independent containerized deployment of the bot service via the new Docker image and example Docker Compose configuration.

Documentation:

  • Document bot service usage, configuration, Docker workflows, API integration, and future phases in the bot README.

Chores:

  • Remove the legacy .prek-config.yaml file now that the tooling configuration has been updated.

JacobCoffee and others added 17 commits November 22, 2025 18:57
Replace hatchling build backend with uv across all packages:
- Root pyproject.toml
- packages/byte-common/pyproject.toml
- services/api/pyproject.toml

Removes hatchling-specific configuration (tool.hatch.build.targets.wheel,
tool.hatch.metadata) that is no longer needed with uv build backend.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add `tool.uv.build-backend.module-root = ""` to support flat layout
where byte_bot/ is at the repository root instead of src/byte_bot/.

This fixes the build error:
  × Failed to build `byte-bot @ file:///home/runner/work/byte/byte`
  ╰─▶ Expected a Python module at: src/byte_bot/__init__.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Update Makefile comment to reference prek
- Remove .pre-commit-config.yaml (using prek now)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Merge PR #109 import fixes into Phase 1.2 branch
- Resolved conflicts in Makefile and pyproject.toml
- Accepted bot service files from PR #109 with all import fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Create multi-stage Dockerfile optimized for production
- Document bot service architecture and usage
- Add development, Docker, and API integration guides
- Mark Phase 1.2 as complete in README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add services/bot/src/byte_bot/__metadata__.py with version info
- Rename .prek-config.yaml back to .pre-commit-config.yaml (prek uses this)
- Apply linter formatting to services/bot/README.md
- All make ci checks passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Nov 23, 2025

Reviewer's Guide

Extracts the Discord bot into a standalone bot service with its own package, configuration, Docker image, and workspace tooling, routes all DB access through the existing API service, and updates tooling/configuration (type checking, prek, Makefile) accordingly.

Sequence diagram for guild configuration update via Byte API

sequenceDiagram
    actor "Discord_User" as Discord_User
    participant "Discord_Client" as Discord_Client
    participant "Bot" as Bot
    participant "API_Client" as API_Client
    participant "Byte_API_Service" as Byte_API_Service
    participant "Database" as Database

    "Discord_User"->>"Discord_Client": "Invoke slash command to update guild config"
    "Discord_Client"->>"Bot": "Send interaction event (update config)"

    "Bot"->>"Bot": "Validate permissions and parse options"
    "Bot"->>"API_Client": "Call 'update_guild(guild_id, guild_data)'"

    "API_Client"->>"Byte_API_Service": "HTTP PUT '/guilds/{guild_id}' with JSON payload"
    "Byte_API_Service"->>"Database": "Execute UPDATE on guild configuration table"
    "Database"-->>"Byte_API_Service": "Return updated guild record"

    "Byte_API_Service"-->>"API_Client": "HTTP 200 OK with updated guild JSON"
    "API_Client"-->>"Bot": "Return parsed guild model or dict"

    "Bot"->>"Discord_Client": "Respond to interaction with success message"
    "Discord_Client"-->>"Discord_User": "Show confirmation that guild settings were updated"
Loading

Class diagram for new bot service settings configuration

classDiagram
    class DiscordSettings {
        +str TOKEN
        +list~str~ COMMAND_PREFIX = ["!"]
        +int DEV_GUILD_ID
        +int DEV_USER_ID
        +int DEV_GUILD_INTERNAL_ID = 1136100160510902272
        +Path PLUGINS_LOC = PLUGINS_DIR
        +list~Path~ PLUGINS_DIRS = [PLUGINS_DIR]
        +str PRESENCE_URL = """"""
        +"assemble_command_prefix(value: list[str]) list[str]"
        +"assemble_presence_url(value: str) str"
        <<env_prefix = "DISCORD_">>
        <<env_file = ".env">>
    }

    class LogSettings {
        +int LEVEL = 20
        +int DISCORD_LEVEL = 30
        +int WEBSOCKETS_LEVEL = 30
        +int ASYNCIO_LEVEL = 20
        +int HTTP_CORE_LEVEL = 20
        +int HTTPX_LEVEL = 30
        +str FORMAT = "[[ %(asctime)s ]] - [[ %(name)s ]] - [[ %(levelname)s ]] - %(message)s"
        +Path FILE = BASE_DIR / "logs" / "byte.log"
        <<env_prefix = "LOG_">>
        <<env_file = ".env">>
    }

    class ProjectSettings {
        +bool DEBUG = False
        +str ENVIRONMENT = "prod"
        +str VERSION = "version from __metadata__"
        <<env_file = ".env">>
    }

    class SettingsModule {
        +DiscordSettings discord
        +LogSettings log
        +ProjectSettings project
        +"load_settings() (DiscordSettings, LogSettings, ProjectSettings)"
    }

    class BaseSettings

    DiscordSettings ..> BaseSettings : inherits
    LogSettings ..> BaseSettings : inherits
    ProjectSettings ..> BaseSettings : inherits

    SettingsModule o-- DiscordSettings : "holds singleton 'discord'"
    SettingsModule o-- LogSettings : "holds singleton 'log'"
    SettingsModule o-- ProjectSettings : "holds singleton 'project'"
Loading

File-Level Changes

Change Details Files
Introduce standalone bot service package and structure, migrating bot code out of the monolith while keeping behavior equivalent.
  • Created services/bot/ workspace service with src/byte_bot layout and pyproject configuration for the byte-bot-service package and entry point
  • Migrated bot core, plugins, views, and lib modules into services/bot/src/byte_bot, preserving existing command and UI behavior
  • Added metadata.py with project name and version metadata for the bot service
services/bot/pyproject.toml
services/bot/src/byte_bot/__init__.py
services/bot/src/byte_bot/__main__.py
services/bot/src/byte_bot/bot.py
services/bot/src/byte_bot/plugins/*
services/bot/src/byte_bot/views/*
services/bot/src/byte_bot/lib/*
services/bot/src/byte_bot/__metadata__.py
Add configuration and settings layer for the bot service, including environment-driven Discord and logging settings.
  • Introduced services/bot/src/byte_bot/config.py as a high-level configuration entry point for the service
  • Implemented settings.py that loads environment variables via pydantic-settings and dotenv, defines DiscordSettings, LogSettings, and ProjectSettings, and exposes module-level instances
  • Added support for environment-derived command prefixes, presence URL, and log configuration including file path and per-library log levels
services/bot/src/byte_bot/config.py
services/bot/src/byte_bot/lib/settings.py
Implement API client abstraction so the bot uses the Byte API for all data access instead of direct DB connections.
  • Added api_client.py to wrap httpx calls to the Byte API with methods for guild CRUD operations
  • Updated bot plugins and core logic to depend on the API client instead of direct database or ORM access
  • Ensured API base URL and related options are configurable via environment and settings layer
services/bot/src/byte_bot/api_client.py
services/bot/src/byte_bot/plugins/*
services/bot/src/byte_bot/lib/*
Define runtime entry points and Dockerized deployment for the bot service as an independent container.
  • Configured main.py and console_script entry point so the bot can be run via uv workspace command, python -m byte_bot, or the byte-bot executable
  • Added a multi-stage Dockerfile that builds dependencies with uv, creates a .venv, and runs the bot under a non-root botuser on Python 3.12-slim
  • Ensured container only depends on the API (no DB client) and copies shared byte-common package into the image
services/bot/src/byte_bot/__main__.py
services/bot/pyproject.toml
services/bot/Dockerfile
Update documentation and developer workflow for the new bot service, including README, Makefile help text, and migration from pre-commit to prek.
  • Rewrote services/bot/README.md with architecture overview, project structure, local run instructions, Docker usage, plugin development guide, and API integration docs
  • Adjusted Makefile install target description to reference prek instead of pre-commit
  • Removed .prek-config.yaml in favor of relying on restored .pre-commit-config.yaml (per earlier PR context)
services/bot/README.md
Makefile
.pre-commit-config.yaml
.prek-config.yaml
Fix imports and type-checking configuration to point at the new bot package and exclude legacy monolith paths.
  • Updated imports across bot modules to use the new byte_bot package paths and fixed previously reported import errors
  • Configured ty to exclude old monolith code and non-bot services from type checking via [tool.ty.src] exclude rules
  • Ensured the new bot service passes ty, ruff, and CI checks as part of make ci
services/bot/src/byte_bot/**/*.py
pyproject.toml
ty configuration files

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@railway-app
Copy link

railway-app bot commented Nov 23, 2025

🚅 Environment byte-pr-111 in byte has no services deployed.

@railway-app railway-app bot temporarily deployed to byte (byte / byte-pr-111) November 23, 2025 04:25 Destroyed
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In lib/settings.py, PLUGINS_DIR still points to 'byte_bot.byte.plugins', which no longer matches the extracted service layout (should be updated to the new 'byte_bot.plugins' path to avoid plugin loading issues).
  • The default log file path (BASE_DIR / 'logs' / 'byte.log') assumes the 'logs' directory exists; consider creating this directory at startup or making the log destination optional to avoid runtime errors in fresh/Docker deployments.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In lib/settings.py, PLUGINS_DIR still points to 'byte_bot.byte.plugins', which no longer matches the extracted service layout (should be updated to the new 'byte_bot.plugins' path to avoid plugin loading issues).
- The default log file path (BASE_DIR / 'logs' / 'byte.log') assumes the 'logs' directory exists; consider creating this directory at startup or making the log destination optional to avoid runtime errors in fresh/Docker deployments.

## Individual Comments

### Comment 1
<location> `services/bot/src/byte_bot/lib/settings.py:64-69` </location>
<code_context>
+        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])
</code_context>

<issue_to_address>
**issue:** Environment lookup can raise a KeyError if ENVIRONMENT is set to an unexpected value.

Since ENVIRONMENT is read from os.getenv("ENVIRONMENT", "dev") and then used as env_urls[environment], any unexpected value (outside {"prod", "test", "dev"}) will cause a runtime KeyError. Consider using env_urls.get(environment, env_urls["dev"]) or a similar fallback to handle unknown environments more gracefully.
</issue_to_address>

### Comment 2
<location> `services/bot/src/byte_bot/lib/settings.py:129` </location>
<code_context>
+
+    DEBUG: bool = False
+    """Run app with ``debug=True``."""
+    ENVIRONMENT: str = "prod"
+    """``dev``, ``prod``, ``test``, etc."""
+    VERSION: str = version
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Default ENVIRONMENT in ProjectSettings is "prod", which is inconsistent with validators defaulting to "dev".

Right now, `ProjectSettings.ENVIRONMENT` defaults to "prod", but the validators for command prefix and presence URL use `os.getenv("ENVIRONMENT", "dev")`. With no env var set, the model says "prod" while the derived values use "dev". If that’s not intentional, please align these defaults or have the validators read from `ProjectSettings.ENVIRONMENT` instead of `os.getenv`.

Suggested implementation:

```python
    DEBUG: bool = False
    """Run app with ``debug=True``."""
    ENVIRONMENT: str = "dev"
    """``dev``, ``prod``, ``test``, etc."""
    VERSION: str = version

```

To fully remove the inconsistency and make `ProjectSettings` the single source of truth, you may also want to:
1) Update any validators or helper functions that currently use `os.getenv("ENVIRONMENT", "dev")` so that they instead read from `ProjectSettings.ENVIRONMENT` (or the equivalent loaded settings instance).
2) For example, if you have something like:
   - `env = os.getenv("ENVIRONMENT", "dev")`
   you should change it to:
   - `env = settings.PROJECT.ENVIRONMENT` (or whatever your loaded settings object is named).
These changes will ensure all derived values and the settings model share the same environment value and default.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +64 to +69
env_urls = {
"prod": "byte ",
"test": "bit ",
"dev": "nibble ",
}
environment = os.getenv("ENVIRONMENT", "dev")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Environment lookup can raise a KeyError if ENVIRONMENT is set to an unexpected value.

Since ENVIRONMENT is read from os.getenv("ENVIRONMENT", "dev") and then used as env_urls[environment], any unexpected value (outside {"prod", "test", "dev"}) will cause a runtime KeyError. Consider using env_urls.get(environment, env_urls["dev"]) or a similar fallback to handle unknown environments more gracefully.


DEBUG: bool = False
"""Run app with ``debug=True``."""
ENVIRONMENT: str = "prod"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Default ENVIRONMENT in ProjectSettings is "prod", which is inconsistent with validators defaulting to "dev".

Right now, ProjectSettings.ENVIRONMENT defaults to "prod", but the validators for command prefix and presence URL use os.getenv("ENVIRONMENT", "dev"). With no env var set, the model says "prod" while the derived values use "dev". If that’s not intentional, please align these defaults or have the validators read from ProjectSettings.ENVIRONMENT instead of os.getenv.

Suggested implementation:

    DEBUG: bool = False
    """Run app with ``debug=True``."""
    ENVIRONMENT: str = "dev"
    """``dev``, ``prod``, ``test``, etc."""
    VERSION: str = version

To fully remove the inconsistency and make ProjectSettings the single source of truth, you may also want to:

  1. Update any validators or helper functions that currently use os.getenv("ENVIRONMENT", "dev") so that they instead read from ProjectSettings.ENVIRONMENT (or the equivalent loaded settings instance).
  2. For example, if you have something like:
    • env = os.getenv("ENVIRONMENT", "dev")
      you should change it to:
    • env = settings.PROJECT.ENVIRONMENT (or whatever your loaded settings object is named).
      These changes will ensure all derived values and the settings model share the same environment value and default.

- Resolve conflict with .prek-config.yaml (removed, using .pre-commit-config.yaml)
- Merge PR #110 (restrict sourcery to local only)
- Merge PR #112 (migrate to dependency-groups.dev)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to byte (byte / byte-pr-111) November 23, 2025 04:31 Destroyed
@JacobCoffee JacobCoffee merged commit 4e6cd58 into main Nov 23, 2025
4 of 5 checks passed
@JacobCoffee JacobCoffee deleted the feature/phase1-extract-api-service branch November 23, 2025 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant