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 <noreply@anthropic.com>
This commit is contained in:
780
DEVELOPMENT.md
780
DEVELOPMENT.md
@@ -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! 🚀
|
|
||||||
196
QUICK_START.md
196
QUICK_START.md
@@ -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.
|
|
||||||
533
README.md
533
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
|
## Features
|
||||||
|
|
||||||
- 🎨 **Image Generation** - Generate images using simple prompts or complex workflows
|
**Discord Bot**
|
||||||
- 🎬 **Video Generation** - Support for video output workflows
|
- Image and video generation via simple prompts or full ComfyUI workflows
|
||||||
- 📝 **Workflow Management** - Load, modify, and execute ComfyUI workflows
|
- Runtime workflow parameter changes (prompt, negative prompt, input images, seeds)
|
||||||
- 📤 **Image Upload** - Upload reference images directly through Discord
|
- Preset management — save and recall workflow configurations
|
||||||
- 📊 **Generation History** - Track and retrieve past generations
|
- Generation history with full output retrieval
|
||||||
- ⚙️ **Runtime Workflow Modification** - Change prompts, negative prompts, and input images on the fly
|
- Server control (start/stop ComfyUI via NSSM service)
|
||||||
- 🔄 **Job Queue System** - Sequential execution prevents server overload
|
- 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))
|
the-third-rev/
|
||||||
- ComfyUI Server running and accessible
|
├── bot.py # Entry point — Discord bot + Uvicorn run via asyncio.gather
|
||||||
- Required packages: `discord.py`, `aiohttp`, `websockets`, `python-dotenv`
|
├── 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**:
|
- Python 3.10+
|
||||||
```bash
|
- Node.js 18+ (for building the frontend)
|
||||||
pip install discord.py aiohttp websockets python-dotenv
|
- ComfyUI running and accessible
|
||||||
```
|
- Discord bot token with **Message Content Intent** enabled
|
||||||
|
|
||||||
3. **Create `.env` file** with your credentials:
|
**Python dependencies:**
|
||||||
```bash
|
```bash
|
||||||
DISCORD_BOT_TOKEN=your_discord_bot_token_here
|
pip install discord.py aiohttp websockets python-dotenv fastapi uvicorn pillow
|
||||||
COMFY_SERVER=localhost:8188
|
```
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run the bot**:
|
## Installation
|
||||||
```bash
|
|
||||||
python bot.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
### 1. Clone and install Python dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
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:
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
# ── Required ────────────────────────────────────────
|
||||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||||
COMFY_SERVER=localhost:8188
|
COMFY_SERVER=localhost:8188
|
||||||
|
|
||||||
# Optional
|
# ── ComfyUI paths ───────────────────────────────────
|
||||||
WORKFLOW_FILE=wan2.2-fast.json
|
COMFY_OUTPUT_PATH=C:\path\to\ComfyUI\output
|
||||||
|
COMFY_INPUT_PATH=C:\path\to\ComfyUI\input
|
||||||
COMFY_HISTORY_LIMIT=10
|
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 |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
|---|---|---|---|
|
||||||
| `DISCORD_BOT_TOKEN` | ✅ Yes | - | Discord bot authentication token |
|
| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token |
|
||||||
| `COMFY_SERVER` | ✅ Yes | - | ComfyUI server address (host:port) |
|
| `COMFY_SERVER` | Yes | — | ComfyUI address (`host:port`) |
|
||||||
| `WORKFLOW_FILE` | ❌ No | - | Path to workflow JSON to load at startup |
|
| `COMFY_OUTPUT_PATH` | No | `...\ComfyUI\output` | ComfyUI output directory |
|
||||||
| `COMFY_HISTORY_LIMIT` | ❌ No | `10` | Number of generations to keep in history |
|
| `COMFY_INPUT_PATH` | No | `...\ComfyUI\input` | ComfyUI input directory |
|
||||||
| `COMFY_OUTPUT_PATH` | ❌ No | `C:\Users\...\ComfyUI\output` | Path to ComfyUI output 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.
|
All commands use the `ttr!` prefix.
|
||||||
|
|
||||||
### Basic Commands
|
### Generation
|
||||||
|
|
||||||
```bash
|
| Command | Alias | Description |
|
||||||
# Test if bot is working
|
|---|---|---|
|
||||||
ttr!test
|
| `ttr!generate prompt:<text>` | `ttr!gen` | Generate using prompt mode |
|
||||||
|
| `ttr!generate prompt:<text> negative_prompt:<text>` | | 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
|
### Workflow management
|
||||||
ttr!generate prompt:a beautiful sunset over mountains
|
|
||||||
|
|
||||||
# Generate with negative prompt
|
| Command | Alias | Description |
|
||||||
ttr!generate prompt:a cat negative_prompt:blurry, low quality
|
|---|---|---|
|
||||||
|
| `ttr!workflow-load <path>` | `ttr!wfl` | Load workflow from file path |
|
||||||
# Execute loaded workflow
|
| `ttr!workflow-load` (+ attachment) | | Load workflow from attached JSON |
|
||||||
ttr!workflow-gen
|
| `ttr!get-current-workflow-changes type:all` | `ttr!gcwc` | Show current overrides |
|
||||||
|
| `ttr!set-current-workflow-changes type:prompt <text>` | `ttr!scwc` | Set prompt override |
|
||||||
# Queue multiple workflow runs
|
| `ttr!set-current-workflow-changes type:negative_prompt <text>` | | Set negative prompt |
|
||||||
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]
|
|
||||||
```
|
|
||||||
|
|
||||||
### History
|
### History
|
||||||
|
|
||||||
```bash
|
| Command | Alias | Description |
|
||||||
# View recent generations
|
|---|---|---|
|
||||||
ttr!history
|
| `ttr!history` | | List recent generations |
|
||||||
|
| `ttr!get-history <id>` | `ttr!gh` | Retrieve output from a past generation |
|
||||||
|
|
||||||
# Retrieve images from a past generation
|
### Presets
|
||||||
ttr!get-history <prompt_id>
|
|
||||||
ttr!get-history 1 # By index
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Aliases
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ttr!preset-save <name>` | Save current workflow overrides as a preset |
|
||||||
|
| `ttr!preset-load <name>` | Apply a saved preset |
|
||||||
|
| `ttr!preset-list` | List all presets |
|
||||||
|
| `ttr!preset-delete <name>` | Delete a preset |
|
||||||
|
|
||||||
Many commands have shorter aliases:
|
### Server control
|
||||||
|
|
||||||
- `ttr!generate` → `ttr!gen`
|
| Command | Description |
|
||||||
- `ttr!workflow-gen` → `ttr!wfg`
|
|---|---|
|
||||||
- `ttr!workflow-load` → `ttr!wfl`
|
| `ttr!server-start` | Start the ComfyUI NSSM service |
|
||||||
- `ttr!get-history` → `ttr!gh`
|
| `ttr!server-stop` | Stop the ComfyUI NSSM service |
|
||||||
- `ttr!get-current-workflow-changes` → `ttr!gcwc`
|
| `ttr!server-status` | Show ComfyUI service status |
|
||||||
- `ttr!set-current-workflow-changes` → `ttr!scwc`
|
| `ttr!server-log` | Tail the ComfyUI log file |
|
||||||
|
|
||||||
## Architecture
|
### Utility
|
||||||
|
|
||||||
The bot is organized into focused, maintainable modules:
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
```
|
| `ttr!test` | Verify bot is online |
|
||||||
the-third-rev/
|
| `ttr!help` | Show command list |
|
||||||
├── 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
|
|
||||||
|
|
||||||
## Workflow System
|
## 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
|
```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:
|
For production, rebuild with `npm run build`.
|
||||||
- Positive prompt in CLIPTextEncode node (title: "Positive Prompt")
|
|
||||||
- Negative prompt in CLIPTextEncode node (title: "Negative Prompt")
|
|
||||||
- Seed values (randomized each run)
|
|
||||||
|
|
||||||
### 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
|
See `CLAUDE.md` for full architectural details.
|
||||||
# Set workflow parameters
|
|
||||||
ttr!set-current-workflow-changes type:prompt A beautiful landscape
|
|
||||||
ttr!set-current-workflow-changes type:input_image input/reference.png
|
|
||||||
|
|
||||||
# Execute workflow
|
### Adding a web API endpoint
|
||||||
ttr!workflow-gen
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot:
|
1. Create a router in `web/routers/`
|
||||||
1. Loads the workflow template
|
2. Register it in `web/app.py` via `app.include_router()`
|
||||||
2. Applies runtime changes from WorkflowStateManager
|
3. Use `require_auth` / `require_admin` from `web/auth.py` for protected routes
|
||||||
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`
|
|
||||||
|
|
||||||
## Troubleshooting
|
## 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`
|
**Web UI WebSocket disconnects immediately**
|
||||||
|
Set a proper `WEB_SECRET_KEY` — an empty secret causes JWT validation failures.
|
||||||
**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
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Built with:
|
Built with:
|
||||||
- [discord.py](https://github.com/Rapptz/discord.py) - Discord API wrapper
|
- [discord.py](https://github.com/Rapptz/discord.py) — Discord API
|
||||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) - Stable Diffusion GUI
|
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) — AI image/video generation backend
|
||||||
- [aiohttp](https://github.com/aio-libs/aiohttp) - Async HTTP client
|
- [FastAPI](https://fastapi.tiangolo.com/) — Web API framework
|
||||||
- [websockets](https://github.com/python-websockets/websockets) - WebSocket implementation
|
- [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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user