204 lines
6.2 KiB
Python
204 lines
6.2 KiB
Python
"""Composition root: owns every singleton, the four services, and the bot."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
import discord
|
|
from discord import Message
|
|
from discord.ext import commands
|
|
|
|
from vibe_bot import llm_client
|
|
from vibe_bot.config import TTS_MODEL_PATH, TTS_VOICES_PATH
|
|
from vibe_bot.database import ChatDatabase, CustomBotManager
|
|
from vibe_bot.llm_client import ToolRegistry
|
|
from vibe_bot.services.chat_service import ChatService
|
|
from vibe_bot.services.conversation_service import ConversationService
|
|
from vibe_bot.services.image_service import ImageService
|
|
from vibe_bot.services.speech_service import SpeechService
|
|
from vibe_bot.tts import TTSEngine
|
|
|
|
if TYPE_CHECKING:
|
|
from discord.ext.commands import Bot
|
|
from discord.ext.commands import Context as CommandsContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def configure_logging() -> None:
|
|
"""Configure root logging (the single ``basicConfig`` in the codebase)."""
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class App:
|
|
"""The composition root: every singleton and the four services."""
|
|
|
|
db: ChatDatabase
|
|
manager: CustomBotManager
|
|
registry: ToolRegistry
|
|
tts: TTSEngine | None
|
|
chat: ChatService
|
|
image: ImageService
|
|
speech: SpeechService
|
|
conversation: ConversationService
|
|
bot_cache: dict[str, tuple[str, str]]
|
|
|
|
|
|
def _build_bot_cache(manager: CustomBotManager) -> dict[str, tuple[str, str]]:
|
|
"""Snapshot the custom bots as name -> (system_prompt, creator)."""
|
|
return {
|
|
name: (system_prompt, creator)
|
|
for name, system_prompt, creator in manager.list_custom_bots()
|
|
}
|
|
|
|
|
|
def invalidate_bot_cache(app: App) -> None:
|
|
"""Rebuild ``app.bot_cache`` from the database after a change."""
|
|
app.bot_cache = _build_bot_cache(app.manager)
|
|
|
|
|
|
def create_app() -> App:
|
|
"""Build the App: singletons, tolerant TTS init, and the four services."""
|
|
db = ChatDatabase()
|
|
manager = CustomBotManager()
|
|
registry = llm_client.get_tool_registry()
|
|
|
|
engine: TTSEngine | None = None
|
|
try:
|
|
engine = TTSEngine(TTS_MODEL_PATH, TTS_VOICES_PATH)
|
|
logger.info("TTS engine initialized successfully")
|
|
except Exception:
|
|
logger.exception("Failed to initialize TTS engine")
|
|
logger.info(
|
|
"Make sure kokoro-v1.0.onnx and voices-v1.0.bin are in the project directory",
|
|
)
|
|
|
|
return App(
|
|
db=db,
|
|
manager=manager,
|
|
registry=registry,
|
|
tts=engine,
|
|
chat=ChatService(db, registry),
|
|
image=ImageService(db, discord.File),
|
|
speech=SpeechService(db, manager, engine, discord.File),
|
|
conversation=ConversationService(manager),
|
|
bot_cache=_build_bot_cache(manager),
|
|
)
|
|
|
|
|
|
# Module-level holders wired by build_bot(); the event handlers below are
|
|
# module-level so they stay importable and testable.
|
|
_app: App | None = None
|
|
_bot: commands.Bot | None = None
|
|
|
|
|
|
def _require_app() -> App:
|
|
"""The App wired by build_bot(); handlers must not run before it."""
|
|
if _app is None:
|
|
msg = "App is not initialized; build_bot(app) must be called first."
|
|
raise RuntimeError(msg)
|
|
return _app
|
|
|
|
|
|
def _require_bot() -> commands.Bot:
|
|
"""The Bot wired by build_bot(); event handlers must not run before it."""
|
|
if _bot is None:
|
|
msg = "Bot is not initialized; build_bot(app) must be called first."
|
|
raise RuntimeError(msg)
|
|
return _bot
|
|
|
|
|
|
async def on_ready() -> None:
|
|
"""Log when the bot is ready and logged in."""
|
|
bot = _require_bot()
|
|
logger.info("Bot is starting up...")
|
|
logger.info("Bot logged in as %s", bot.user)
|
|
|
|
|
|
async def on_message(message: Message) -> None:
|
|
"""Handle incoming messages for custom bot command detection."""
|
|
app = _require_app()
|
|
bot = _require_bot()
|
|
|
|
if message.author == bot.user:
|
|
return
|
|
|
|
if not message.content.startswith("!"):
|
|
return
|
|
|
|
message_content = message.content.lower()
|
|
logger.debug(
|
|
"Processing message from user %s (chars=%d)",
|
|
message.author.id,
|
|
len(message_content),
|
|
)
|
|
|
|
for bot_name, (system_prompt, _creator) in app.bot_cache.items():
|
|
if message_content.startswith(f"!{bot_name} "):
|
|
logger.info(
|
|
"Custom bot %r triggered by user %s", bot_name, message.author.id
|
|
)
|
|
|
|
user_message = message.content[len(f"!{bot_name} ") :]
|
|
logger.debug(
|
|
"Extracted user message for bot %r (chars=%d)",
|
|
bot_name,
|
|
len(user_message),
|
|
)
|
|
|
|
response_prefix = f"{bot_name} response"
|
|
logger.info("Sending request to chat service for bot %r", bot_name)
|
|
ctx = await bot.get_context(message)
|
|
await app.chat.handle(
|
|
ctx=ctx,
|
|
bot_name=bot_name,
|
|
message=user_message,
|
|
system_prompt=system_prompt,
|
|
response_prefix=response_prefix,
|
|
)
|
|
return
|
|
|
|
# If no custom bot matched, call the default event handler
|
|
await bot.process_commands(message)
|
|
|
|
|
|
async def on_command_error(
|
|
ctx: CommandsContext[Bot],
|
|
error: commands.CommandError,
|
|
) -> None:
|
|
"""Send a friendly message when a command hits its per-user cooldown."""
|
|
if isinstance(error, commands.CommandOnCooldown):
|
|
retry_after = max(1, round(error.retry_after))
|
|
await ctx.send(f"You're using that too quickly, try again in {retry_after}s.")
|
|
return
|
|
logger.exception("Unhandled command error in %s: %s", ctx.command, error)
|
|
|
|
|
|
def build_bot(app: App) -> commands.Bot:
|
|
"""Create the Discord bot with all event and command handlers attached."""
|
|
global _app, _bot
|
|
_app = app
|
|
|
|
intents = discord.Intents.default()
|
|
intents.message_content = True
|
|
intents.members = True
|
|
intents.presences = True
|
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
|
_bot = bot
|
|
|
|
bot.event(on_ready)
|
|
bot.event(on_message)
|
|
bot.event(on_command_error)
|
|
|
|
# Imported here (not at module top) to break the import cycle.
|
|
from vibe_bot.commands import register_all
|
|
|
|
register_all(bot, app)
|
|
return bot
|