From f6907d2c39db7048bb7320e2870e6f3a818f7c56 Mon Sep 17 00:00:00 2001 From: "Khoa (Revenovich) Tran Gia" Date: Mon, 2 Mar 2026 12:00:13 +0700 Subject: [PATCH] docs: update README for current architecture, remove stale docs Rewrote README.md to reflect the actual project state: dual Discord bot + FastAPI web UI, WorkflowInspector-based dynamic node injection, no SerialJobQueue, full env var table, frontend build steps, web auth setup, and updated credits/troubleshooting. Removed three files that were actively misleading: - DEVELOPMENT.md and QUICK_START.md referenced deleted modules (job_queue.py, upload.py) and old WorkflowManager/StateManager APIs - backfill_image_data.py was a completed one-shot migration script Co-Authored-By: Claude Sonnet 4.6 --- DEVELOPMENT.md | 780 ----------------------------------------- QUICK_START.md | 196 ----------- README.md | 533 ++++++++++++++-------------- backfill_image_data.py | 156 --------- 4 files changed, 254 insertions(+), 1411 deletions(-) delete mode 100644 DEVELOPMENT.md delete mode 100644 QUICK_START.md delete mode 100644 backfill_image_data.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 51acdc5..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,780 +0,0 @@ -# Development Guide - -This guide explains how to add new commands, features, and modules to the Discord ComfyUI bot. - -## Table of Contents - -- [Adding a New Command](#adding-a-new-command) -- [Adding a New Feature Module](#adding-a-new-feature-module) -- [Adding Configuration Options](#adding-configuration-options) -- [Working with Workflows](#working-with-workflows) -- [Best Practices](#best-practices) -- [Common Patterns](#common-patterns) -- [Testing Your Changes](#testing-your-changes) - ---- - -## Adding a New Command - -Commands are organized in the `commands/` directory by functionality. Here's how to add a new command: - -### Step 1: Choose the Right Module - -Determine which existing command module your command belongs to: - -- **generation.py** - Image/video generation commands -- **workflow.py** - Workflow template management -- **upload.py** - File upload commands -- **history.py** - History viewing and retrieval -- **workflow_changes.py** - Runtime workflow parameter management - -If none fit, create a new module (see [Adding a New Feature Module](#adding-a-new-feature-module)). - -### Step 2: Add Your Command Function - -Edit the appropriate module in `commands/` and add your command to the `setup_*_commands()` function: - -```python -# commands/generation.py - -def setup_generation_commands(bot, config): - # ... existing commands ... - - @bot.command(name="my-new-command", aliases=["mnc", "my-cmd"]) - @require_comfy_client # Use this decorator if you need bot.comfy - async def my_new_command(ctx: commands.Context, *, args: str = "") -> None: - """ - Brief description of what your command does. - - Usage: - ttr!my-new-command [arguments] - - Longer description with examples and details. - """ - # Parse arguments if needed - if not args: - await ctx.reply("Please provide arguments!", mention_author=False) - return - - try: - # Your command logic here - result = await bot.comfy.some_method(args) - - # Send response - await ctx.reply(f"Success! Result: {result}", mention_author=False) - - except Exception as exc: - logger.exception("Failed to execute my-new-command") - await ctx.reply( - f"An error occurred: {type(exc).__name__}: {exc}", - mention_author=False, - ) -``` - -### Step 3: Import Required Dependencies - -At the top of your command module, import what you need: - -```python -import logging -from discord.ext import commands -from discord_utils import require_comfy_client, parse_labeled_args -from config import ARG_PROMPT_KEY, ARG_TYPE_KEY -from job_queue import Job - -logger = logging.getLogger(__name__) -``` - -### Step 4: Test Your Command - -Run the bot and test your command: - -```bash -python bot.py -``` - -Then in Discord: -``` -ttr!my-new-command test arguments -``` - ---- - -## Adding a New Feature Module - -If your commands don't fit existing modules, create a new one: - -### Step 1: Create the Module File - -Create `commands/your_feature.py`: - -```python -""" -commands/your_feature.py -======================== - -Description of what this module handles. -""" - -from __future__ import annotations - -import logging -from discord.ext import commands -from discord_utils import require_comfy_client - -logger = logging.getLogger(__name__) - - -def setup_your_feature_commands(bot, config): - """ - Register your feature commands with the bot. - - Parameters - ---------- - bot : commands.Bot - The Discord bot instance. - config : BotConfig - The bot configuration object. - """ - - @bot.command(name="feature-command") - @require_comfy_client - async def feature_command(ctx: commands.Context, *, args: str = "") -> None: - """Command description.""" - await ctx.reply("Feature command executed!", mention_author=False) - - @bot.command(name="another-command", aliases=["ac"]) - async def another_command(ctx: commands.Context) -> None: - """Another command description.""" - await ctx.reply("Another command!", mention_author=False) -``` - -### Step 2: Register in commands/__init__.py - -Edit `commands/__init__.py` to import and register your module: - -```python -from .generation import setup_generation_commands -from .workflow import setup_workflow_commands -from .upload import setup_upload_commands -from .history import setup_history_commands -from .workflow_changes import setup_workflow_changes_commands -from .your_feature import setup_your_feature_commands # ADD THIS - - -def register_all_commands(bot, config): - """Register all bot commands.""" - setup_generation_commands(bot, config) - setup_workflow_commands(bot) - setup_upload_commands(bot) - setup_history_commands(bot) - setup_workflow_changes_commands(bot) - setup_your_feature_commands(bot, config) # ADD THIS -``` - -### Step 3: Update Documentation - -Add your module to `CLAUDE.md`: - -```markdown -### File Structure - -``` -commands/ -├── __init__.py -├── generation.py -├── workflow.py -├── upload.py -├── history.py -├── workflow_changes.py -└── your_feature.py # Your new module -``` -``` - ---- - -## Adding Configuration Options - -Configuration is centralized in `config.py`. Here's how to add new options: - -### Step 1: Add Constants (if needed) - -Edit `config.py` and add constants in the appropriate section: - -```python -# ======================================== -# Your Feature Constants -# ======================================== - -MY_FEATURE_DEFAULT_VALUE = 42 -"""Default value for my feature.""" - -MY_FEATURE_MAX_LIMIT = 100 -"""Maximum limit for my feature.""" -``` - -### Step 2: Add to BotConfig (if environment variable) - -If your config comes from environment variables, add it to `BotConfig`: - -```python -@dataclass -class BotConfig: - """Configuration container for the Discord ComfyUI bot.""" - - discord_bot_token: str - comfy_server: str - comfy_output_path: str - comfy_history_limit: int - workflow_file: Optional[str] = None - my_feature_enabled: bool = False # ADD THIS - my_feature_value: int = MY_FEATURE_DEFAULT_VALUE # ADD THIS -``` - -### Step 3: Load in from_env() - -Add loading logic in `BotConfig.from_env()`: - -```python -@classmethod -def from_env(cls) -> BotConfig: - """Create a BotConfig instance by loading from environment.""" - # ... existing code ... - - # Load your feature config - my_feature_enabled = os.getenv("MY_FEATURE_ENABLED", "false").lower() == "true" - - try: - my_feature_value = int(os.getenv("MY_FEATURE_VALUE", str(MY_FEATURE_DEFAULT_VALUE))) - except ValueError: - my_feature_value = MY_FEATURE_DEFAULT_VALUE - - return cls( - # ... existing parameters ... - my_feature_enabled=my_feature_enabled, - my_feature_value=my_feature_value, - ) -``` - -### Step 4: Use in Your Commands - -Access config in your commands: - -```python -def setup_your_feature_commands(bot, config): - @bot.command(name="feature") - async def feature_command(ctx: commands.Context): - if not config.my_feature_enabled: - await ctx.reply("Feature is disabled!", mention_author=False) - return - - value = config.my_feature_value - await ctx.reply(f"Feature value: {value}", mention_author=False) -``` - -### Step 5: Document the Environment Variable - -Update `CLAUDE.md` and add to `.env.example` (if you create one): - -```bash -# Feature Configuration -MY_FEATURE_ENABLED=true -MY_FEATURE_VALUE=42 -``` - ---- - -## Working with Workflows - -The bot has separate concerns for workflows: - -- **WorkflowManager** (`workflow_manager.py`) - Template storage and node manipulation -- **WorkflowStateManager** (`workflow_state.py`) - Runtime state (prompt, negative_prompt, input_image) -- **ComfyClient** (`comfy_client.py`) - Uses both managers to generate images - -### Adding New Workflow Node Types - -If you need to manipulate new types of nodes in workflows: - -#### Step 1: Add Method to WorkflowManager - -Edit `workflow_manager.py`: - -```python -def find_and_replace_my_node( - self, workflow: Dict[str, Any], my_value: str -) -> Dict[str, Any]: - """ - Find and replace my custom node type. - - This searches for nodes of a specific class_type and updates their inputs. - - Parameters - ---------- - workflow : Dict[str, Any] - The workflow definition to modify. - my_value : str - The value to inject. - - Returns - ------- - Dict[str, Any] - The modified workflow. - """ - for node_id, node in workflow.items(): - if node.get("class_type") == "MyCustomNodeType" and node.get("inputs"): - # Check metadata for specific node identification - meta = node.get("_meta", {}) - if "My Custom Node" in meta.get("title", ""): - workflow[node_id]["inputs"]["my_input"] = my_value - logger.debug("Replaced my_value in node %s", node_id) - - return workflow -``` - -#### Step 2: Add to apply_state_changes() - -Update `apply_state_changes()` to include your new manipulation: - -```python -def apply_state_changes( - self, - workflow: Dict[str, Any], - prompt: Optional[str] = None, - negative_prompt: Optional[str] = None, - input_image: Optional[str] = None, - my_custom_value: Optional[str] = None, # ADD THIS - randomize_seed: bool = True, -) -> Dict[str, Any]: - """Apply multiple state changes to a workflow in one pass.""" - if randomize_seed: - workflow = self.find_and_replace_seed(workflow) - - if prompt is not None: - workflow = self.find_and_replace_prompt(workflow, prompt) - - if negative_prompt is not None: - workflow = self.find_and_replace_negative_prompt(workflow, negative_prompt) - - if input_image is not None: - workflow = self.find_and_replace_input_image(workflow, input_image) - - # ADD THIS - if my_custom_value is not None: - workflow = self.find_and_replace_my_node(workflow, my_custom_value) - - return workflow -``` - -#### Step 3: Add State to WorkflowStateManager - -Edit `workflow_state.py` to track the new state: - -```python -def __init__(self, state_file: Optional[str] = None): - """Initialize the workflow state manager.""" - self._state: Dict[str, Any] = { - "prompt": None, - "negative_prompt": None, - "input_image": None, - "my_custom_value": None, # ADD THIS - } - # ... rest of init ... - -def set_my_custom_value(self, value: str) -> None: - """Set the custom value.""" - self._state["my_custom_value"] = value - if self._state_file: - try: - self.save_to_file() - except Exception: - pass - -def get_my_custom_value(self) -> Optional[str]: - """Get the custom value.""" - return self._state.get("my_custom_value") -``` - -#### Step 4: Use in ComfyClient - -The ComfyClient will automatically use your new state if you update `generate_image_with_workflow()`: - -```python -async def generate_image_with_workflow(self) -> tuple[List[bytes], List[dict[str, Any]], str]: - # ... existing code ... - - # Get current state changes - changes = self.state_manager.get_changes() - prompt = changes.get("prompt") - negative_prompt = changes.get("negative_prompt") - input_image = changes.get("input_image") - my_custom_value = changes.get("my_custom_value") # ADD THIS - - # Apply changes using WorkflowManager - workflow = self.workflow_manager.apply_state_changes( - workflow, - prompt=prompt, - negative_prompt=negative_prompt, - input_image=input_image, - my_custom_value=my_custom_value, # ADD THIS - randomize_seed=True, - ) - - # ... rest of method ... -``` - ---- - -## Best Practices - -### Command Design - -1. **Use descriptive names**: `ttr!generate` is better than `ttr!gen` (but provide aliases) -2. **Validate inputs early**: Check arguments before starting long operations -3. **Provide clear feedback**: Tell users what's happening and when it's done -4. **Handle errors gracefully**: Catch exceptions and show user-friendly messages -5. **Use decorators**: `@require_comfy_client` eliminates boilerplate - -### Code Organization - -1. **One responsibility per module**: Don't mix unrelated commands -2. **Keep functions small**: If a function is > 50 lines, consider splitting it -3. **Use type hints**: Help future developers understand your code -4. **Document with docstrings**: Explain what, why, and how - -### Discord Best Practices - -1. **Use `mention_author=False`**: Prevents spam from @mentions -2. **Use `delete_after=X`**: For temporary status messages -3. **Use `ephemeral=True`**: For interaction responses (buttons/modals) -4. **Limit file attachments**: Discord has a 4-file limit (use `MAX_IMAGES_PER_RESPONSE`) - -### Performance - -1. **Use job queue for long operations**: Queue generation requests -2. **Use typing indicator**: `async with ctx.typing():` shows bot is working -3. **Batch operations**: Don't send 10 separate messages when 1 will do -4. **Close resources**: Always close aiohttp sessions, file handles - ---- - -## Common Patterns - -### Pattern 1: Labeled Argument Parsing - -For commands with `key:value` syntax: - -```python -from discord_utils import parse_labeled_args -from config import ARG_PROMPT_KEY, ARG_TYPE_KEY - -@bot.command(name="my-command") -async def my_command(ctx: commands.Context, *, args: str = ""): - # Parse labeled arguments - parsed = parse_labeled_args(args, [ARG_PROMPT_KEY, ARG_TYPE_KEY]) - - prompt = parsed.get("prompt") # None if not provided - image_type = parsed.get("type") or "input" # Default to "input" - - if not prompt: - await ctx.reply("Please provide a prompt!", mention_author=False) - return -``` - -### Pattern 2: Queued Job Execution - -For long-running operations: - -```python -from job_queue import Job - -@bot.command(name="long-operation") -@require_comfy_client -async def long_operation(ctx: commands.Context, *, args: str = ""): - try: - # Define the job function - async def _run_job(): - async with ctx.typing(): - result = await bot.comfy.some_long_operation(args) - await ctx.reply(f"Done! Result: {result}", mention_author=False) - - # Submit to queue - position = await bot.jobq.submit( - Job( - label=f"long-operation:{ctx.author.id}", - run=_run_job, - ) - ) - - await ctx.reply( - f"Queued ✅ (position: {position})", - mention_author=False, - delete_after=2.0, - ) - - except Exception as exc: - logger.exception("Failed to queue long operation") - await ctx.reply( - f"An error occurred: {type(exc).__name__}: {exc}", - mention_author=False, - ) -``` - -### Pattern 3: File Attachments - -For uploading files to ComfyUI: - -```python -@bot.command(name="upload-and-process") -@require_comfy_client -async def upload_and_process(ctx: commands.Context): - if not ctx.message.attachments: - await ctx.reply("Please attach a file!", mention_author=False) - return - - for attachment in ctx.message.attachments: - try: - # Download attachment - data = await attachment.read() - - # Upload to ComfyUI - result = await bot.comfy.upload_image( - data, - attachment.filename, - image_type="input", - ) - - # Process the uploaded file - filename = result.get("name") - await ctx.reply(f"Uploaded: {filename}", mention_author=False) - - except Exception as exc: - logger.exception("Failed to process attachment") - await ctx.reply( - f"Failed: {attachment.filename}: {exc}", - mention_author=False, - ) -``` - -### Pattern 4: Interactive UI (Buttons) - -For adding buttons to messages: - -```python -from discord.ui import View, Button -import discord - -class MyView(View): - def __init__(self, data: str): - super().__init__(timeout=None) - self.data = data - - @discord.ui.button(label="Click Me", style=discord.ButtonStyle.primary) - async def button_callback( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - # Handle button click - await interaction.response.send_message( - f"You clicked! Data: {self.data}", - ephemeral=True, - ) - -@bot.command(name="interactive") -async def interactive(ctx: commands.Context): - view = MyView(data="example") - await ctx.reply("Click the button:", view=view, mention_author=False) -``` - -### Pattern 5: Using Configuration - -Access bot configuration in commands: - -```python -def setup_my_commands(bot, config): - @bot.command(name="check-config") - async def check_config(ctx: commands.Context): - # Access config values - server = config.comfy_server - output_path = config.comfy_output_path - - await ctx.reply( - f"Server: {server}\nOutput: {output_path}", - mention_author=False, - ) -``` - ---- - -## Testing Your Changes - -### Manual Testing Checklist - -1. **Start the bot**: `python bot.py` - - Verify no import errors - - Check configuration loads correctly - - Confirm all commands register - -2. **Test basic functionality**: - ``` - ttr!test - ttr!help - ttr!your-new-command - ``` - -3. **Test error handling**: - - Run command with missing arguments - - Run command with invalid arguments - - Test when ComfyUI is unavailable (if applicable) - -4. **Test edge cases**: - - Very long inputs - - Special characters - - Concurrent command execution - - Commands while queue is full - -### Syntax Validation - -Check for syntax errors without running: - -```bash -python -m py_compile bot.py -python -m py_compile commands/your_feature.py -``` - -### Check Imports - -Verify all imports work: - -```bash -python -c "from commands.your_feature import setup_your_feature_commands; print('OK')" -``` - -### Code Style - -Follow these conventions: - -- **Indentation**: 4 spaces (no tabs) -- **Line length**: Max 100 characters (documentation can be longer) -- **Docstrings**: Use Google style or NumPy style (match existing code) -- **Imports**: Group stdlib, third-party, local (separated by blank lines) -- **Type hints**: Use modern syntax (`dict[str, Any]` not `Dict[str, Any]`) - ---- - -## Example: Complete Feature Addition - -Here's a complete example adding a "status" command: - -### 1. Create commands/status.py - -```python -""" -commands/status.py -================== - -Bot status and diagnostics commands. -""" - -from __future__ import annotations - -import logging -import asyncio -from discord.ext import commands -from discord_utils import require_comfy_client - -logger = logging.getLogger(__name__) - - -def setup_status_commands(bot, config): - """Register status commands with the bot.""" - - @bot.command(name="status", aliases=["s", "stat"]) - async def status_command(ctx: commands.Context) -> None: - """ - Show bot status and queue information. - - Usage: - ttr!status - - Displays: - - Bot connection status - - ComfyUI connection status - - Current queue size - - Configuration info - """ - # Check bot status - latency_ms = round(bot.latency * 1000) - - # Check queue - if hasattr(bot, "jobq"): - queue_size = await bot.jobq.get_queue_size() - else: - queue_size = 0 - - # Check ComfyUI - comfy_status = "✅ Connected" if hasattr(bot, "comfy") else "❌ Not configured" - - # Build status message - status_msg = [ - "**Bot Status**", - f"• Latency: {latency_ms}ms", - f"• Queue size: {queue_size}", - f"• ComfyUI: {comfy_status}", - f"• Server: {config.comfy_server}", - ] - - await ctx.reply("\n".join(status_msg), mention_author=False) - - @bot.command(name="ping") - async def ping_command(ctx: commands.Context) -> None: - """ - Check bot responsiveness. - - Usage: - ttr!ping - """ - latency_ms = round(bot.latency * 1000) - await ctx.reply(f"🏓 Pong! Latency: {latency_ms}ms", mention_author=False) -``` - -### 2. Register in commands/__init__.py - -```python -from .status import setup_status_commands - -def register_all_commands(bot, config): - # ... existing registrations ... - setup_status_commands(bot, config) -``` - -### 3. Update CLAUDE.md - -```markdown -- **commands/status.py** - Bot status and diagnostics commands -``` - -### 4. Test - -```bash -python bot.py -``` - -In Discord: -``` -ttr!status -ttr!ping -ttr!s (alias test) -``` - ---- - -## Getting Help - -If you're stuck: - -1. **Check CLAUDE.md**: Architecture and patterns documented there -2. **Read existing commands**: See how similar features are implemented -3. **Check logs**: Run bot and check console output for errors -4. **Test incrementally**: Add small pieces and test frequently - -Remember: The refactored architecture makes adding features straightforward. Follow the patterns, and your code will fit right in! 🚀 diff --git a/QUICK_START.md b/QUICK_START.md deleted file mode 100644 index c24f231..0000000 --- a/QUICK_START.md +++ /dev/null @@ -1,196 +0,0 @@ -# Quick Start Guide - -Quick reference for common development tasks. - -## Add a Simple Command - -```python -# commands/your_module.py - -def setup_your_commands(bot, config): - @bot.command(name="hello") - async def hello(ctx): - await ctx.reply("Hello!", mention_author=False) -``` - -Register it: -```python -# commands/__init__.py -from .your_module import setup_your_commands - -def register_all_commands(bot, config): - # ... existing ... - setup_your_commands(bot, config) -``` - -## Add a Command That Uses ComfyUI - -```python -from discord_utils import require_comfy_client - -@bot.command(name="my-cmd") -@require_comfy_client # Validates bot.comfy exists -async def my_cmd(ctx): - result = await bot.comfy.some_method() - await ctx.reply(f"Result: {result}", mention_author=False) -``` - -## Add a Long-Running Command - -```python -from job_queue import Job - -@bot.command(name="generate") -@require_comfy_client -async def generate(ctx, *, args: str = ""): - async def _run(): - async with ctx.typing(): - result = await bot.comfy.generate_image(args) - await ctx.reply(f"Done! {result}", mention_author=False) - - pos = await bot.jobq.submit(Job(label="generate", run=_run)) - await ctx.reply(f"Queued ✅ (position: {pos})", mention_author=False) -``` - -## Add Configuration - -```python -# config.py - -MY_FEATURE_ENABLED = True - -@dataclass -class BotConfig: - # ... existing fields ... - my_feature_enabled: bool = MY_FEATURE_ENABLED - - @classmethod - def from_env(cls) -> BotConfig: - # ... existing code ... - my_feature = os.getenv("MY_FEATURE_ENABLED", "true").lower() == "true" - return cls( - # ... existing params ... - my_feature_enabled=my_feature - ) -``` - -Use in commands: -```python -def setup_my_commands(bot, config): - @bot.command(name="feature") - async def feature(ctx): - if config.my_feature_enabled: - await ctx.reply("Enabled!", mention_author=False) -``` - -## Parse Command Arguments - -```python -from discord_utils import parse_labeled_args -from config import ARG_PROMPT_KEY, ARG_TYPE_KEY - -@bot.command(name="cmd") -async def cmd(ctx, *, args: str = ""): - # Parse "prompt:text type:value" format - parsed = parse_labeled_args(args, [ARG_PROMPT_KEY, ARG_TYPE_KEY]) - - prompt = parsed.get("prompt") - img_type = parsed.get("type") or "input" # Default - - if not prompt: - await ctx.reply("Missing prompt!", mention_author=False) - return -``` - -## Handle File Uploads - -```python -@bot.command(name="upload") -async def upload(ctx): - if not ctx.message.attachments: - await ctx.reply("Attach a file!", mention_author=False) - return - - for attachment in ctx.message.attachments: - data = await attachment.read() - # Process data... -``` - -## Access Bot State - -```python -@bot.command(name="info") -async def info(ctx): - # Queue size - queue_size = await bot.jobq.get_queue_size() - - # Config - server = bot.config.comfy_server - - # Last generation - last_id = bot.comfy.last_prompt_id - - await ctx.reply( - f"Queue: {queue_size}, Server: {server}, Last: {last_id}", - mention_author=False - ) -``` - -## Add Buttons - -```python -from discord.ui import View, Button -import discord - -class MyView(View): - @discord.ui.button(label="Click", style=discord.ButtonStyle.primary) - async def button_callback(self, interaction, button): - await interaction.response.send_message("Clicked!", ephemeral=True) - -@bot.command(name="interactive") -async def interactive(ctx): - await ctx.reply("Press button:", view=MyView(), mention_author=False) -``` - -## Common Imports - -```python -from __future__ import annotations -import logging -from discord.ext import commands -from discord_utils import require_comfy_client -from config import ARG_PROMPT_KEY, ARG_TYPE_KEY -from job_queue import Job - -logger = logging.getLogger(__name__) -``` - -## Test Your Changes - -```bash -# Syntax check -python -m py_compile commands/your_module.py - -# Run bot -python bot.py - -# Test in Discord -ttr!your-command -``` - -## File Structure - -``` -commands/ -├── __init__.py # Register all commands here -├── generation.py # Generation commands -├── workflow.py # Workflow management -├── upload.py # File uploads -├── history.py # History retrieval -├── workflow_changes.py # State management -└── your_module.py # Your new module -``` - -## Need More Details? - -See `DEVELOPMENT.md` for comprehensive guide with examples, patterns, and best practices. diff --git a/README.md b/README.md index e340325..b6f9e47 100644 --- a/README.md +++ b/README.md @@ -1,345 +1,320 @@ -# Discord ComfyUI Bot +# ComfyUI Discord Bot + Web UI -A Discord bot that integrates with ComfyUI to generate AI images and videos through Discord commands. +A Discord bot and web interface that integrates with [ComfyUI](https://github.com/comfyanonymous/ComfyUI) to generate AI images and videos. Requests can be submitted through Discord commands or a browser-based UI with real-time progress updates. ## Features -- 🎨 **Image Generation** - Generate images using simple prompts or complex workflows -- 🎬 **Video Generation** - Support for video output workflows -- 📝 **Workflow Management** - Load, modify, and execute ComfyUI workflows -- 📤 **Image Upload** - Upload reference images directly through Discord -- 📊 **Generation History** - Track and retrieve past generations -- ⚙️ **Runtime Workflow Modification** - Change prompts, negative prompts, and input images on the fly -- 🔄 **Job Queue System** - Sequential execution prevents server overload +**Discord Bot** +- Image and video generation via simple prompts or full ComfyUI workflows +- Runtime workflow parameter changes (prompt, negative prompt, input images, seeds) +- Preset management — save and recall workflow configurations +- Generation history with full output retrieval +- Server control (start/stop ComfyUI via NSSM service) +- Automatic image compression to fit Discord's 8 MiB limit -## Quick Start +**Web UI** +- Invite-token authentication with JWT session cookies +- Simple generate form and full dynamic workflow form (auto-discovers all node inputs) +- Input image library — upload, browse, and select images for generation +- Generation history with image preview +- Preset management +- Real-time status dashboard (ComfyUI connection, queue depth) +- WebSocket-based live progress updates during generation +- Admin panel for token and server management -### Prerequisites +## Architecture -- Python 3.9+ -- Discord Bot Token ([create one here](https://discord.com/developers/applications)) -- ComfyUI Server running and accessible -- Required packages: `discord.py`, `aiohttp`, `websockets`, `python-dotenv` +``` +the-third-rev/ +├── bot.py # Entry point — Discord bot + Uvicorn run via asyncio.gather +├── config.py # BotConfig dataclass, loads all env vars +├── comfy_client.py # ComfyUI REST + WebSocket client +├── workflow_inspector.py # Dynamic node discovery and override injection +├── workflow_manager.py # Workflow template storage (get/set) +├── workflow_state.py # Runtime override dict with file persistence +├── generation_db.py # SQLite generation history + file BLOBs +├── input_image_db.py # SQLite input image storage +├── token_store.py # SHA-256 hashed invite tokens +├── preset_manager.py # Workflow preset CRUD +├── user_state_registry.py # Per-user workflow state for web sessions +├── image_utils.py # PIL-based image compression +├── media_uploader.py # Optional external media upload +├── status_monitor.py # Background status polling and Discord log channel +├── discord_utils.py # Discord helpers, decorators, argument parsing +├── commands/ # Discord command handlers +│ ├── __init__.py +│ ├── generation.py # generate, workflow-gen +│ ├── workflow.py # workflow-load +│ ├── history.py # history, get-history +│ ├── input_images.py # input image upload/management +│ ├── presets.py # preset save/load/delete +│ ├── server.py # ComfyUI server control +│ ├── utility.py # test, info, misc +│ ├── workflow_changes.py # get/set workflow overrides +│ └── help_command.py # custom help +├── web/ # FastAPI application +│ ├── app.py # App factory, middleware, static file serving +│ ├── auth.py # JWT create/verify +│ ├── deps.py # Shared FastAPI dependencies (bot reference) +│ ├── login_guard.py # Auth decorators +│ ├── ws_bus.py # Per-user WebSocket broadcast bus +│ └── routers/ # API endpoints (generate, history, inputs, presets, ...) +└── frontend/ # React + TypeScript + Vite + Tailwind source + └── src/ + ├── pages/ # GeneratePage, WorkflowPage, HistoryPage, ... + ├── components/ # Layout, DynamicWorkflowForm, LazyImage + ├── hooks/ # useAuth, useWebSocket, useStatus + └── context/ # GenerationContext (pending count badge) +``` -### Installation +ComfyUI's own queue handles job ordering — no separate job queue exists in this bot. Generation callbacks are matched to requests via a `prompt_id → callback` map in `ComfyClient`. -1. **Clone or download this repository** +## Requirements -2. **Install dependencies**: - ```bash - pip install discord.py aiohttp websockets python-dotenv - ``` +- Python 3.10+ +- Node.js 18+ (for building the frontend) +- ComfyUI running and accessible +- Discord bot token with **Message Content Intent** enabled -3. **Create `.env` file** with your credentials: - ```bash - DISCORD_BOT_TOKEN=your_discord_bot_token_here - COMFY_SERVER=localhost:8188 - ``` +**Python dependencies:** +```bash +pip install discord.py aiohttp websockets python-dotenv fastapi uvicorn pillow +``` -4. **Run the bot**: - ```bash - python bot.py - ``` +## Installation -## Configuration +### 1. Clone and install Python dependencies + +```bash +git clone +cd the-third-rev +pip install discord.py aiohttp websockets python-dotenv fastapi uvicorn pillow +``` + +### 2. Build the frontend + +```bash +cd frontend +npm install +npm run build +cd .. +``` + +The build output lands in `web-static/` and is served automatically by FastAPI. + +### 3. Configure environment Create a `.env` file in the project root: ```bash -# Required +# ── Required ──────────────────────────────────────── DISCORD_BOT_TOKEN=your_discord_bot_token COMFY_SERVER=localhost:8188 -# Optional -WORKFLOW_FILE=wan2.2-fast.json +# ── ComfyUI paths ─────────────────────────────────── +COMFY_OUTPUT_PATH=C:\path\to\ComfyUI\output +COMFY_INPUT_PATH=C:\path\to\ComfyUI\input COMFY_HISTORY_LIMIT=10 -COMFY_OUTPUT_PATH=C:\Users\YourName\Documents\ComfyUI\output + +# ── Startup workflow ──────────────────────────────── +WORKFLOW_FILE=workflows/my_workflow.json # optional; loaded at startup + +# ── Web UI ────────────────────────────────────────── +WEB_ENABLED=true +WEB_HOST=0.0.0.0 +WEB_PORT=8080 +WEB_SECRET_KEY=change-me-to-a-random-secret +WEB_JWT_EXPIRE_HOURS=720 +WEB_SECURE_COOKIE=false # set true if serving over HTTPS + +# ── Admin ─────────────────────────────────────────── +ADMIN_PASSWORD=your_admin_password + +# ── ComfyUI server control (optional) ─────────────── +COMFY_SERVICE_NAME=ComfyUI # NSSM service name +COMFY_START_BAT=C:\path\to\run_nvidia_gpu.bat +COMFY_LOG_DIR=C:\path\to\logs +COMFY_AUTOSTART=false # auto-start ComfyUI on bot launch + +# ── Discord status log channel (optional) ──────────── +LOG_CHANNEL_ID=123456789012345678 + +# ── External media upload (optional) ──────────────── +MEDIA_UPLOAD_USER= +MEDIA_UPLOAD_PASS= ``` -### Configuration Options +### Full configuration reference | Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `DISCORD_BOT_TOKEN` | ✅ Yes | - | Discord bot authentication token | -| `COMFY_SERVER` | ✅ Yes | - | ComfyUI server address (host:port) | -| `WORKFLOW_FILE` | ❌ No | - | Path to workflow JSON to load at startup | -| `COMFY_HISTORY_LIMIT` | ❌ No | `10` | Number of generations to keep in history | -| `COMFY_OUTPUT_PATH` | ❌ No | `C:\Users\...\ComfyUI\output` | Path to ComfyUI output directory | +|---|---|---|---| +| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token | +| `COMFY_SERVER` | Yes | — | ComfyUI address (`host:port`) | +| `COMFY_OUTPUT_PATH` | No | `...\ComfyUI\output` | ComfyUI output directory | +| `COMFY_INPUT_PATH` | No | `...\ComfyUI\input` | ComfyUI input directory | +| `COMFY_HISTORY_LIMIT` | No | `10` | Generations kept in history | +| `WORKFLOW_FILE` | No | — | Workflow JSON to load at startup | +| `WEB_ENABLED` | No | `true` | Enable web UI | +| `WEB_HOST` | No | `0.0.0.0` | Web server bind address | +| `WEB_PORT` | No | `8080` | Web server port | +| `WEB_SECRET_KEY` | No | — | JWT signing secret (set in production) | +| `WEB_JWT_EXPIRE_HOURS` | No | `720` | Session expiry in hours | +| `WEB_SECURE_COOKIE` | No | `false` | Mark session cookie as Secure (HTTPS only) | +| `WEB_TOKEN_FILE` | No | `invite_tokens.json` | Invite token storage path | +| `ADMIN_PASSWORD` | No | — | Admin panel password | +| `COMFY_SERVICE_NAME` | No | — | NSSM service name for server control | +| `COMFY_START_BAT` | No | — | ComfyUI launch script path | +| `COMFY_LOG_DIR` | No | — | Directory for ComfyUI logs | +| `COMFY_AUTOSTART` | No | `false` | Auto-start ComfyUI on bot launch | +| `LOG_CHANNEL_ID` | No | — | Discord channel ID for status messages | -## Usage +### 4. Create a web UI invite token + +The web UI requires an invite token to register an account: + +```bash +python -c "from token_store import create_token; print(create_token('username'))" +``` + +Copy the printed token — it is shown only once. Give it to the user who will register. + +### 5. Run + +```bash +python bot.py +``` + +The bot starts the Discord client and the web server concurrently. Navigate to `http://localhost:8080` to access the web UI. + +## Discord Commands All commands use the `ttr!` prefix. -### Basic Commands +### Generation -```bash -# Test if bot is working -ttr!test +| Command | Alias | Description | +|---|---|---| +| `ttr!generate prompt:` | `ttr!gen` | Generate using prompt mode | +| `ttr!generate prompt: negative_prompt:` | | Generate with negative prompt | +| `ttr!workflow-gen` | `ttr!wfg` | Execute loaded workflow with current overrides | +| `ttr!workflow-gen queue:5` | | Queue 5 workflow runs | -# Generate an image with a prompt -ttr!generate prompt:a beautiful sunset over mountains +### Workflow management -# Generate with negative prompt -ttr!generate prompt:a cat negative_prompt:blurry, low quality - -# Execute loaded workflow -ttr!workflow-gen - -# Queue multiple workflow runs -ttr!workflow-gen queue:5 -``` - -### Workflow Management - -```bash -# Load a workflow from file -ttr!workflow-load path/to/workflow.json - -# Or attach a JSON file to the message: -ttr!workflow-load -[Attach: my_workflow.json] - -# View current workflow changes -ttr!get-current-workflow-changes type:all - -# Set workflow parameters -ttr!set-current-workflow-changes type:prompt A new prompt -ttr!set-current-workflow-changes type:negative_prompt blurry -ttr!set-current-workflow-changes type:input_image input/image.png -``` - -### Image Upload - -```bash -# Upload images to ComfyUI -ttr!upload -[Attach: image1.png, image2.png] - -# Upload to specific folder -ttr!upload type:temp -[Attach: reference.png] -``` +| Command | Alias | Description | +|---|---|---| +| `ttr!workflow-load ` | `ttr!wfl` | Load workflow from file path | +| `ttr!workflow-load` (+ attachment) | | Load workflow from attached JSON | +| `ttr!get-current-workflow-changes type:all` | `ttr!gcwc` | Show current overrides | +| `ttr!set-current-workflow-changes type:prompt ` | `ttr!scwc` | Set prompt override | +| `ttr!set-current-workflow-changes type:negative_prompt ` | | Set negative prompt | ### History -```bash -# View recent generations -ttr!history +| Command | Alias | Description | +|---|---|---| +| `ttr!history` | | List recent generations | +| `ttr!get-history ` | `ttr!gh` | Retrieve output from a past generation | -# Retrieve images from a past generation -ttr!get-history -ttr!get-history 1 # By index -``` +### Presets -### Command Aliases +| Command | Description | +|---|---| +| `ttr!preset-save ` | Save current workflow overrides as a preset | +| `ttr!preset-load ` | Apply a saved preset | +| `ttr!preset-list` | List all presets | +| `ttr!preset-delete ` | Delete a preset | -Many commands have shorter aliases: +### Server control -- `ttr!generate` → `ttr!gen` -- `ttr!workflow-gen` → `ttr!wfg` -- `ttr!workflow-load` → `ttr!wfl` -- `ttr!get-history` → `ttr!gh` -- `ttr!get-current-workflow-changes` → `ttr!gcwc` -- `ttr!set-current-workflow-changes` → `ttr!scwc` +| Command | Description | +|---|---| +| `ttr!server-start` | Start the ComfyUI NSSM service | +| `ttr!server-stop` | Stop the ComfyUI NSSM service | +| `ttr!server-status` | Show ComfyUI service status | +| `ttr!server-log` | Tail the ComfyUI log file | -## Architecture +### Utility -The bot is organized into focused, maintainable modules: - -``` -the-third-rev/ -├── config.py # Configuration and constants -├── job_queue.py # Job queue system -├── workflow_manager.py # Workflow manipulation -├── workflow_state.py # Runtime state management -├── discord_utils.py # Discord utilities -├── bot.py # Main entry point (~150 lines) -├── comfy_client.py # ComfyUI API client (~650 lines) -└── commands/ # Command handlers - ├── generation.py # Image/video generation - ├── workflow.py # Workflow management - ├── upload.py # File uploads - ├── history.py # History retrieval - └── workflow_changes.py # State management -``` - -### Key Design Principles - -- **Dependency Injection** - Dependencies passed via constructor -- **Single Responsibility** - Each module has one clear purpose -- **Configuration Centralization** - All config in `config.py` -- **Command Separation** - Commands grouped by functionality -- **Type Safety** - Modern Python type hints throughout - -## Development - -### Adding a New Command - -See `QUICK_START.md` for quick examples or `DEVELOPMENT.md` for comprehensive guide. - -Basic example: - -```python -# commands/your_module.py - -def setup_your_commands(bot, config): - @bot.command(name="hello") - async def hello(ctx): - await ctx.reply("Hello!", mention_author=False) -``` - -Register in `commands/__init__.py`: - -```python -from .your_module import setup_your_commands - -def register_all_commands(bot, config): - # ... existing ... - setup_your_commands(bot, config) -``` - -### Documentation - -- **README.md** (this file) - Project overview and setup -- **QUICK_START.md** - Quick reference for common tasks -- **DEVELOPMENT.md** - Comprehensive development guide -- **CLAUDE.md** - Architecture documentation for Claude Code +| Command | Description | +|---|---| +| `ttr!test` | Verify bot is online | +| `ttr!help` | Show command list | ## Workflow System -The bot supports two generation modes: +### How node injection works -### 1. Prompt Mode (Simple) +`workflow_inspector.py` dynamically discovers all controllable inputs in any workflow: -Uses a workflow template with a KSampler node: +- **Prompt** — `CLIPTextEncode` node with title containing "Positive Prompt" +- **Negative prompt** — `CLIPTextEncode` node with title containing "Negative Prompt" +- **Input image** — `LoadImage` nodes (first one = `input_image` key; additional ones get slugified title keys) +- **Seed** — any node with `inputs.seed` or `inputs.noise_seed` (auto-randomized unless explicitly set) +- **Steps, CFG, checkpoint, LoRA** — discovered and injectable via the web workflow form + +No hardcoded node IDs. Workflows only need to follow standard ComfyUI node title conventions. + +### Workflow overrides persist across restarts + +Runtime changes are saved to `current-workflow-changes.json` automatically and restored on startup. + +### Loading a custom workflow + +1. Design and export your workflow in ComfyUI (Save → API Format) +2. Load it in Discord: `ttr!workflow-load path/to/workflow.json` + or via the web UI: Workflow page → Upload +3. Set overrides and run: `ttr!workflow-gen` or use the web Generate/Workflow page + +## Development + +### Frontend development ```bash -ttr!generate prompt:a cat negative_prompt:blurry +cd frontend +npm run dev # HMR dev server on :5173, proxies /api + /ws to :8080 ``` -The bot automatically finds and replaces: -- Positive prompt in CLIPTextEncode node (title: "Positive Prompt") -- Negative prompt in CLIPTextEncode node (title: "Negative Prompt") -- Seed values (randomized each run) +For production, rebuild with `npm run build`. -### 2. Workflow Mode (Advanced) +### Adding a Discord command -Execute full workflow with runtime modifications: +1. Add your handler to the appropriate module in `commands/` (or create a new one) +2. Register it in `commands/__init__.py` → `register_all_commands()` +3. Use `@require_comfy_client` from `discord_utils.py` if the command needs `bot.comfy` -```bash -# Set workflow parameters -ttr!set-current-workflow-changes type:prompt A beautiful landscape -ttr!set-current-workflow-changes type:input_image input/reference.png +See `CLAUDE.md` for full architectural details. -# Execute workflow -ttr!workflow-gen -``` +### Adding a web API endpoint -The bot: -1. Loads the workflow template -2. Applies runtime changes from WorkflowStateManager -3. Randomizes seeds -4. Executes on ComfyUI server -5. Returns images/videos - -### Node Naming Conventions - -For workflows to work with dynamic updates, nodes must follow naming conventions: - -- **Positive Prompt**: CLIPTextEncode node with title containing "Positive Prompt" -- **Negative Prompt**: CLIPTextEncode node with title containing "Negative Prompt" -- **Input Image**: LoadImage node (any title) -- **Seeds**: Any node with `inputs.seed` or `inputs.noise_seed` +1. Create a router in `web/routers/` +2. Register it in `web/app.py` via `app.include_router()` +3. Use `require_auth` / `require_admin` from `web/auth.py` for protected routes ## Troubleshooting -### Bot won't start +**Web UI shows a blank page in production** +Windows may serve `.js` files as `text/plain`. This is fixed in `web/app.py` with explicit MIME type registration — ensure you are running the latest version. -**Issue**: `AttributeError: module 'queue' has no attribute 'SimpleQueue'` +**ComfyUI connection refused** +Check `COMFY_SERVER` in `.env` and confirm ComfyUI is running. Test with `curl http://localhost:8188`. -**Solution**: This was fixed by renaming `queue.py` to `job_queue.py`. Make sure you're using the latest version. +**Commands not responding** +Ensure the bot has **Message Content Intent** enabled in the Discord Developer Portal and has sufficient channel permissions. -### ComfyUI connection issues +**Videos not delivered** +Set `COMFY_OUTPUT_PATH` to the correct ComfyUI output directory. The bot reads video files directly from disk. -**Issue**: `ComfyUI client is not configured` - -**Solution**: -1. Check `.env` file has `DISCORD_BOT_TOKEN` and `COMFY_SERVER` -2. Verify ComfyUI server is running -3. Test connection: `curl http://localhost:8188` - -### Commands not responding - -**Issue**: Bot online but commands don't work - -**Solution**: -1. Check bot has Message Content Intent enabled in Discord Developer Portal -2. Verify bot has permissions in Discord server -3. Check console logs for errors - -### Video files not found - -**Issue**: `Failed to read video file` - -**Solution**: -1. Set `COMFY_OUTPUT_PATH` in `.env` to your ComfyUI output directory -2. Check path uses correct format for your OS - -## Advanced Usage - -### Batch Generation - -Queue multiple workflow runs: - -```bash -ttr!workflow-gen queue:10 -``` - -Each run uses randomized seeds for variation. - -### Custom Workflows - -1. Design workflow in ComfyUI -2. Export as API format (Save → API Format) -3. Load in bot: - ```bash - ttr!workflow-load path/to/workflow.json - ``` -4. Modify at runtime: - ```bash - ttr!set-current-workflow-changes type:prompt My prompt - ttr!workflow-gen - ``` - -### State Persistence - -Workflow changes are automatically saved to `current-workflow-changes.json` and persist across bot restarts. - -## Contributing - -We welcome contributions! Please: - -1. Read `DEVELOPMENT.md` for coding guidelines -2. Follow existing code style and patterns -3. Test your changes thoroughly -4. Update documentation as needed - -## License - -[Your License Here] - -## Support - -For issues or questions: -- Check the troubleshooting section above -- Review `DEVELOPMENT.md` for implementation details -- Check ComfyUI documentation for workflow issues -- Open an issue on GitHub +**Web UI WebSocket disconnects immediately** +Set a proper `WEB_SECRET_KEY` — an empty secret causes JWT validation failures. ## Credits Built with: -- [discord.py](https://github.com/Rapptz/discord.py) - Discord API wrapper -- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) - Stable Diffusion GUI -- [aiohttp](https://github.com/aio-libs/aiohttp) - Async HTTP client -- [websockets](https://github.com/python-websockets/websockets) - WebSocket implementation +- [discord.py](https://github.com/Rapptz/discord.py) — Discord API +- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) — AI image/video generation backend +- [FastAPI](https://fastapi.tiangolo.com/) — Web API framework +- [Uvicorn](https://www.uvicorn.org/) — ASGI server +- [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [Tailwind CSS](https://tailwindcss.com/) — Web frontend +- [aiohttp](https://github.com/aio-libs/aiohttp) — Async HTTP client +- [Pillow](https://python-pillow.org/) — Image compression diff --git a/backfill_image_data.py b/backfill_image_data.py deleted file mode 100644 index 5d340bf..0000000 --- a/backfill_image_data.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -backfill_image_data.py -====================== - -One-shot script to download image bytes from Discord and store them in -input_images.db for rows that currently have image_data = NULL. - -These rows were created before the BLOB-storage migration, so their bytes -were never persisted. The script re-fetches each bot-reply message from -Discord and writes the raw attachment bytes back into the DB. - -Rows with bot_reply_id = 0 (web uploads that pre-date the migration) have -no Discord source and are skipped — re-upload them via the web UI to -backfill. - -Usage ------ - python backfill_image_data.py - -Requires: - DISCORD_BOT_TOKEN in .env (same token the bot uses) -""" - -from __future__ import annotations - -import asyncio -import logging -import sqlite3 - -import discord - -try: - from dotenv import load_dotenv - load_dotenv() -except Exception: - pass - -from config import BotConfig -from input_image_db import DB_PATH - -logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") -logger = logging.getLogger(__name__) - - -def _load_null_rows() -> list[dict]: - """Return all rows that are missing image_data.""" - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - rows = conn.execute( - "SELECT id, bot_reply_id, channel_id, filename" - " FROM input_images WHERE image_data IS NULL" - ).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def _save_image_data(row_id: int, data: bytes) -> None: - conn = sqlite3.connect(str(DB_PATH)) - conn.execute("UPDATE input_images SET image_data = ? WHERE id = ?", (data, row_id)) - conn.commit() - conn.close() - - -async def _backfill(client: discord.Client) -> None: - rows = _load_null_rows() - - discord_rows = [r for r in rows if r["bot_reply_id"] != 0] - web_rows = [r for r in rows if r["bot_reply_id"] == 0] - - logger.info( - "Rows missing image_data: %d total (%d from Discord, %d web-uploads skipped)", - len(rows), len(discord_rows), len(web_rows), - ) - - if web_rows: - logger.info( - "Skipped row IDs (no Discord source — re-upload via web UI): %s", - [r["id"] for r in web_rows], - ) - - if not discord_rows: - logger.info("Nothing to fetch. Exiting.") - return - - ok = 0 - failed = 0 - - for row in discord_rows: - row_id = row["id"] - ch_id = row["channel_id"] - msg_id = row["bot_reply_id"] - filename = row["filename"] - - try: - channel = client.get_channel(ch_id) or await client.fetch_channel(ch_id) - message = await channel.fetch_message(msg_id) - - attachment = next( - (a for a in message.attachments if a.filename == filename), None - ) - if attachment is None: - logger.warning( - "Row %d: attachment '%s' not found on message %d — skipping", - row_id, filename, msg_id, - ) - failed += 1 - continue - - data = await attachment.read() - _save_image_data(row_id, data) - logger.info("Row %d: saved '%s' (%d bytes)", row_id, filename, len(data)) - ok += 1 - - except discord.NotFound: - logger.warning("Row %d: message %d not found (deleted?) — skipping", row_id, msg_id) - failed += 1 - except discord.Forbidden: - logger.warning("Row %d: no access to channel %d — skipping", row_id, ch_id) - failed += 1 - except Exception as exc: - logger.error("Row %d: unexpected error — %s", row_id, exc) - failed += 1 - - logger.info( - "Done. %d saved, %d failed/skipped, %d web-upload rows not touched.", - ok, failed, len(web_rows), - ) - - -async def _main(token: str) -> None: - intents = discord.Intents.none() # no gateway events needed beyond connect - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - logger.info("Logged in as %s", client.user) - try: - await _backfill(client) - finally: - await client.close() - - await client.start(token) - - -def main() -> None: - try: - config = BotConfig.from_env() - except RuntimeError as exc: - logger.error("Config error: %s", exc) - return - - asyncio.run(_main(config.discord_bot_token)) - - -if __name__ == "__main__": - main()