Files
comfy-discord-web/bot.py
Khoa (Revenovich) Tran Gia 1748cbf8d2 fix: suppress Uvicorn WebSocket rejection noise in logs
Unknown-path WS probes (e.g. /waapi) generate three INFO lines from
uvicorn.access + uvicorn.error on every attempt. Install a logging.Filter
on both loggers at startup to drop:
  - access log entries:  "WebSocket <path>" 403
  - error log entries:   "connection rejected ..." / "connection closed"

These are handled gracefully by _SPAStaticFiles; the logs add no value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:52:44 +07:00

244 lines
7.7 KiB
Python

"""
bot.py
======
Discord bot entry point. In WEB_ENABLED mode, also starts a FastAPI/Uvicorn
web server in the same asyncio event loop via asyncio.gather.
Jobs are submitted directly to ComfyUI — no internal SerialJobQueue.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
import discord
from discord.ext import commands
from comfy_client import ComfyClient
from config import BotConfig, COMMAND_PREFIX
import generation_db
from input_image_db import init_db, get_all_images
from status_monitor import StatusMonitor
from workflow_manager import WorkflowManager
from workflow_state import WorkflowStateManager
from commands import register_all_commands, CustomHelpCommand
from commands.input_images import PersistentSetInputView
from commands.server import autostart_comfy
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class _UvicornWSNoiseFilter(logging.Filter):
"""Suppress per-connection WebSocket rejection noise from Uvicorn.
When an unknown path (e.g. /waapi probes) hits the server as a WebSocket
upgrade, Uvicorn logs three lines at INFO:
- uvicorn.access: '... "WebSocket /waapi" 403'
- uvicorn.error: 'connection rejected (403 Forbidden)'
- uvicorn.error: 'connection closed'
These are expected and handled gracefully; we just don't want them filling
the log.
"""
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if '"WebSocket ' in msg and ' 403' in msg:
return False
if msg.startswith("connection rejected") or msg == "connection closed":
return False
return True
_PROJECT_ROOT = Path(__file__).resolve().parent
# ---------------------------------------------------------------------------
# Bot setup
# ---------------------------------------------------------------------------
def get_prefix(bot, message):
"""Dynamic command prefix getter."""
msg = message.content.lower()
if msg.startswith(COMMAND_PREFIX):
return COMMAND_PREFIX
return COMMAND_PREFIX
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(
command_prefix=get_prefix,
intents=intents,
help_command=CustomHelpCommand(),
)
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
@bot.event
async def on_ready() -> None:
logger.info("Logged in as %s (ID: %s)", bot.user, bot.user.id)
if not hasattr(bot, "start_time"):
bot.start_time = datetime.now(timezone.utc)
cfg = getattr(bot, "config", None)
if cfg:
for row in get_all_images():
view = PersistentSetInputView(bot, cfg, row["id"])
bot.add_view(view, message_id=row["bot_reply_id"])
asyncio.create_task(autostart_comfy(cfg))
if not hasattr(bot, "status_monitor"):
log_ch = getattr(getattr(bot, "config", None), "log_channel_id", None)
if log_ch:
bot.status_monitor = StatusMonitor(bot, log_ch)
if hasattr(bot, "status_monitor"):
await bot.status_monitor.start()
@bot.event
async def on_disconnect() -> None:
logger.info("Discord connection closed")
@bot.event
async def on_resumed() -> None:
logger.info("Discord session resumed")
# ---------------------------------------------------------------------------
# Startup helpers
# ---------------------------------------------------------------------------
async def create_comfy(config: BotConfig) -> ComfyClient:
state_manager = WorkflowStateManager(state_file="current-workflow-changes.json")
workflow_manager = WorkflowManager()
return ComfyClient(
server_address=config.comfy_server,
workflow_manager=workflow_manager,
state_manager=state_manager,
logger=logger,
history_limit=config.comfy_history_limit,
output_path=config.comfy_output_path,
)
def _try_autoload_last_workflow(client: ComfyClient) -> None:
"""Re-load the last used workflow from the workflows/ folder on startup."""
last_wf = client.state_manager.get_last_workflow_file()
if not last_wf:
return
wf_path = _PROJECT_ROOT / "workflows" / last_wf
if not wf_path.exists():
logger.warning("Last workflow file not found: %s", wf_path)
return
try:
with open(wf_path, "r", encoding="utf-8") as f:
workflow = json.load(f)
# Restore template without clearing overrides on restart
client.workflow_manager.set_workflow_template(workflow)
logger.info("Auto-loaded last workflow: %s", last_wf)
except Exception as exc:
logger.error("Failed to auto-load workflow %s: %s", last_wf, exc)
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def main() -> None:
try:
config: BotConfig = BotConfig.from_env()
logger.info("Configuration loaded")
except RuntimeError as exc:
logger.error("Configuration error: %s", exc)
return
bot.comfy = await create_comfy(config)
bot.config = config
# Auto-load last workflow (restores template without clearing overrides)
_try_autoload_last_workflow(bot.comfy)
# Fallback: WORKFLOW_FILE env var
if not bot.comfy.get_workflow_template() and config.workflow_file:
try:
bot.comfy.load_workflow_from_file(config.workflow_file)
logger.info("Loaded workflow from %s", config.workflow_file)
except Exception as exc:
logger.error("Failed to load workflow %s: %s", config.workflow_file, exc)
from user_state_registry import UserStateRegistry
bot.user_registry = UserStateRegistry(
settings_dir=_PROJECT_ROOT / "user_settings",
default_workflow=bot.comfy.get_workflow_template(),
)
init_db()
generation_db.init_db(_PROJECT_ROOT / "generation_history.db")
register_all_commands(bot, config)
logger.info("All commands registered")
coroutines = [bot.start(config.discord_bot_token)]
if config.web_enabled:
try:
import uvicorn
from web.deps import set_bot
from web.app import create_app
set_bot(bot)
fastapi_app = create_app()
uvi_config = uvicorn.Config(
fastapi_app,
host=config.web_host,
port=config.web_port,
log_level="info",
loop="none", # use existing event loop
)
uvi_server = uvicorn.Server(uvi_config)
_ws_filter = _UvicornWSNoiseFilter()
logging.getLogger("uvicorn.access").addFilter(_ws_filter)
logging.getLogger("uvicorn.error").addFilter(_ws_filter)
coroutines.append(uvi_server.serve())
logger.info(
"Web UI enabled at http://%s:%d", config.web_host, config.web_port
)
except ImportError:
logger.warning(
"uvicorn or fastapi not installed — web UI disabled. "
"pip install fastapi uvicorn[standard]"
)
try:
await asyncio.gather(*coroutines)
finally:
if hasattr(bot, "status_monitor"):
await bot.status_monitor.stop()
if hasattr(bot, "comfy"):
await bot.comfy.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Received interrupt, shutting down…")