complete restructure
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Discord command groups; ``register_all`` wires every group onto the bot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe_bot.commands import admin, chat, conversation, custom_bots, images, speech
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register_all(bot: commands.Bot, app: App) -> None:
|
||||
"""Register every command group on the bot."""
|
||||
custom_bots.register(bot, app)
|
||||
speech.register(bot, app)
|
||||
images.register(bot, app)
|
||||
conversation.register(bot, app)
|
||||
admin.register(bot, app)
|
||||
chat.register(bot, app)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared App holder for the command modules, wired by each group's register."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe_bot.app import App
|
||||
|
||||
_app: App | None = None
|
||||
|
||||
|
||||
def set_app(app: App) -> None:
|
||||
"""Store the App so command handlers can reach the services."""
|
||||
global _app
|
||||
_app = app
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Admin and debug commands: wipe history, debug menu, chat history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
from vibe_bot.prompts import get_user_info
|
||||
from vibe_bot.textutil import split_message
|
||||
from vibe_bot.tools import get_channel_members, get_channel_members_impl
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the admin and debug commands."""
|
||||
set_app(app)
|
||||
bot.command(name="lobotomize")(lobotomize)
|
||||
bot.command(name="debug")(debug)
|
||||
bot.command(name="history")(history)
|
||||
|
||||
|
||||
async def lobotomize(ctx: CommandsContext[Bot]) -> None:
|
||||
"""Clear all conversation history and memory for all bots."""
|
||||
app = require_app()
|
||||
logger.info("Lobotomize command triggered by user %s", ctx.author.id)
|
||||
await asyncio.to_thread(app.db.clear_all_messages)
|
||||
await ctx.send("All conversation history and memory has been cleared. 🧠✨")
|
||||
|
||||
|
||||
async def debug(
|
||||
ctx: CommandsContext[Bot],
|
||||
*,
|
||||
subcommand: str | None = None,
|
||||
) -> None:
|
||||
"""Debug menu for various debugging sub-commands.
|
||||
|
||||
Usage: !debug <subcommand>
|
||||
Available sub-commands:
|
||||
- members: List all members in the current channel
|
||||
- whoami: Show all information the bot has about you
|
||||
- tools: Show the LLM's available tools
|
||||
"""
|
||||
logger.info(
|
||||
"Debug command triggered by user %s with subcommand %r",
|
||||
ctx.author.id,
|
||||
subcommand,
|
||||
)
|
||||
|
||||
if not subcommand:
|
||||
menu = "Debug Menu:\n\n"
|
||||
menu += "Available sub-commands:\n"
|
||||
menu += "- `members` - List all members in the current channel\n"
|
||||
menu += "- `whoami` - Show all information the bot has about you\n"
|
||||
menu += "- `tools` - Show the LLM's available tools"
|
||||
await ctx.send(menu)
|
||||
return
|
||||
|
||||
if subcommand == "members":
|
||||
result = get_channel_members_impl(ctx.channel)
|
||||
for chunk in split_message(result):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
if subcommand == "whoami":
|
||||
user_info = get_user_info(ctx.author)
|
||||
for chunk in split_message(user_info):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
if subcommand == "tools":
|
||||
tool_list = "LLM Tools:\n\n"
|
||||
tool_list += f"- `{get_channel_members.name}`\n"
|
||||
tool_list += f" Description: {get_channel_members.description}\n"
|
||||
args_schema = get_channel_members.args_schema
|
||||
if isinstance(args_schema, type):
|
||||
tool_list += f" Parameters: {args_schema.model_json_schema()}"
|
||||
for chunk in split_message(tool_list):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
await ctx.send(
|
||||
f"Unknown debug sub-command: `{subcommand}`\n\n"
|
||||
f"Use `!debug` to see available sub-commands.",
|
||||
)
|
||||
|
||||
|
||||
async def history(ctx: CommandsContext[Bot], bot_name: str) -> None:
|
||||
"""View the chat history of a custom bot.
|
||||
|
||||
Usage: !history <bot_name>
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"History command triggered by user %s for bot %r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
bot_info = await asyncio.to_thread(app.manager.get_custom_bot, bot_name)
|
||||
|
||||
if not bot_info:
|
||||
await ctx.send(f"Custom bot '{bot_name}' not found.")
|
||||
return
|
||||
|
||||
history = await asyncio.to_thread(
|
||||
app.db.get_bot_history, bot_name=bot_name, limit=20
|
||||
)
|
||||
|
||||
if not history:
|
||||
await ctx.send(f"No chat history found for **{bot_name}**. ")
|
||||
return
|
||||
|
||||
history.reverse()
|
||||
|
||||
formatted_history: list[str] = []
|
||||
for user_msg, bot_resp in history:
|
||||
formatted_history.append(user_msg)
|
||||
formatted_history.append(f"{bot_name}: {bot_resp}")
|
||||
|
||||
header = f"Chat History for **{bot_name}**:\n\n"
|
||||
full_text = header + "\n---\n".join(formatted_history)
|
||||
|
||||
for chunk in split_message(full_text):
|
||||
await ctx.send(chunk)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Custom-bot chat has no command of its own, so this group registers nothing.
|
||||
|
||||
A message like ``!alfred hello`` is matched against the bot-name cache in
|
||||
``vibe_bot.app.on_message`` and dispatched to ``ChatService.handle``; ordinary
|
||||
``!`` messages fall through to the commands registered by the other groups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register no commands: custom-bot chat flows through ``on_message``."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Bot-vs-bot conversation commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
MIN_TALKFORME_PARTS = 4
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the conversation commands."""
|
||||
set_app(app)
|
||||
bot.command(name="talkforme")(talkforme)
|
||||
|
||||
|
||||
@commands.cooldown(rate=1, per=30, type=commands.BucketType.user)
|
||||
async def talkforme(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Have two bots talk to each other about a topic.
|
||||
|
||||
Usage: !talkforme bot1 bot2 4 some conversation topic
|
||||
"""
|
||||
app = require_app()
|
||||
parts = message.split(" ", maxsplit=MIN_TALKFORME_PARTS - 1)
|
||||
if len(parts) < MIN_TALKFORME_PARTS:
|
||||
await ctx.send("Usage: !talkforme bot1 bot2 <number> <topic>")
|
||||
return
|
||||
|
||||
await app.conversation.run(ctx, parts[0], parts[1], parts[2], " ".join(parts[3:]))
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Custom-bot management commands: create, list, and delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import invalidate_bot_cache
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_BOT_NAME_LENGTH = 2
|
||||
MAX_BOT_NAME_LENGTH = 50
|
||||
MIN_PERSONALITY_LENGTH = 10
|
||||
MAX_PERSONALITY_LENGTH = 1000
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the custom-bot management commands."""
|
||||
set_app(app)
|
||||
bot.command(name="custom-bot")(custom_bot)
|
||||
bot.command(name="list-custom-bots")(list_custom_bots)
|
||||
bot.command(name="delete-custom-bot")(delete_custom_bot)
|
||||
|
||||
|
||||
async def custom_bot(
|
||||
ctx: CommandsContext[Bot],
|
||||
bot_name: str,
|
||||
*,
|
||||
personality: str,
|
||||
) -> None:
|
||||
"""Create a custom bot with a name and personality.
|
||||
|
||||
Usage: !custom-bot <bot_name> <personality_description>
|
||||
Example: !custom-bot alfred you are a proper british butler
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"Custom bot command initiated by user %s: name=%r, personality_chars=%d",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
len(personality),
|
||||
)
|
||||
|
||||
# Validate bot name
|
||||
name_length = 0 if not bot_name else len(bot_name)
|
||||
if (
|
||||
not bot_name
|
||||
or name_length < MIN_BOT_NAME_LENGTH
|
||||
or name_length > MAX_BOT_NAME_LENGTH
|
||||
):
|
||||
logger.warning(
|
||||
"Invalid bot name from user %s: %r (length: %d)",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
name_length,
|
||||
)
|
||||
await ctx.send("Invalid bot name. Name must be between 2 and 50 characters.")
|
||||
return
|
||||
|
||||
logger.debug("Bot name validation passed for %r", bot_name)
|
||||
|
||||
# Validate personality
|
||||
personality_length = 0 if not personality else len(personality)
|
||||
if not personality or personality_length < MIN_PERSONALITY_LENGTH:
|
||||
logger.warning(
|
||||
"Invalid personality from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
personality_length,
|
||||
)
|
||||
await ctx.send(
|
||||
"Invalid personality. Description must be at least 10 characters.",
|
||||
)
|
||||
return
|
||||
if personality_length > MAX_PERSONALITY_LENGTH:
|
||||
logger.warning(
|
||||
"Personality too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
personality_length,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Personality too long. " f"Max {MAX_PERSONALITY_LENGTH} characters.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug("Personality validation passed for bot %r", bot_name)
|
||||
|
||||
# Create the custom bot
|
||||
logger.debug(
|
||||
"Attempting to create custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
app.manager.create_custom_bot,
|
||||
bot_name=bot_name,
|
||||
system_prompt=personality,
|
||||
created_by=str(ctx.author.id),
|
||||
)
|
||||
|
||||
if result is False:
|
||||
logger.warning(
|
||||
"Failed to create custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send("Failed to create custom bot.")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(invalidate_bot_cache, app)
|
||||
|
||||
if result == "replaced":
|
||||
logger.info(
|
||||
"Replaced existing custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Custom bot **'{bot_name}'** already existed and has been "
|
||||
f"**replaced** with personality: *{personality}*",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Successfully created custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Custom bot **'{bot_name}'** has been created "
|
||||
f"with personality: *{personality}*",
|
||||
)
|
||||
await ctx.send(
|
||||
f"\nYou can now use this bot with: " f"`!{bot_name} <your message>`",
|
||||
)
|
||||
|
||||
|
||||
async def list_custom_bots(ctx: CommandsContext[Bot]) -> None:
|
||||
"""List all custom bots available in the server."""
|
||||
app = require_app()
|
||||
logger.info("Listing custom bots requested by user %s", ctx.author.id)
|
||||
|
||||
logger.debug("Fetching list of custom bots from database")
|
||||
bots = await asyncio.to_thread(app.manager.list_custom_bots)
|
||||
|
||||
if not bots:
|
||||
logger.debug("No custom bots found for user %s", ctx.author.id)
|
||||
await ctx.send(
|
||||
"No custom bots have been created yet. "
|
||||
"Use `!custom-bot <name> <personality>` to create one.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Found %d custom bots, displaying top 10 for user %s",
|
||||
len(bots),
|
||||
ctx.author.id,
|
||||
)
|
||||
bot_list = "Available Custom Bots:\n\n"
|
||||
for name, _prompt, _creator in bots:
|
||||
bot_list += f"* {name}\n"
|
||||
|
||||
logger.debug("Sending bot list response to user %s", ctx.author.id)
|
||||
await ctx.send(bot_list)
|
||||
|
||||
|
||||
async def delete_custom_bot(ctx: CommandsContext[Bot], bot_name: str) -> None:
|
||||
"""Delete a custom bot (only the creator can delete).
|
||||
|
||||
Usage: !delete-custom-bot <bot_name>
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"Delete custom bot command initiated by user %s: bot_name=%r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
# Get bot info
|
||||
logger.debug("Looking up custom bot %r in database", bot_name)
|
||||
bot_info = await asyncio.to_thread(app.manager.get_custom_bot, bot_name)
|
||||
|
||||
if not bot_info:
|
||||
logger.warning(
|
||||
"Custom bot %r not found by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(f"Custom bot '{bot_name}' not found.")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Custom bot %r found, owned by user %s",
|
||||
bot_name,
|
||||
bot_info[2],
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if bot_info[2] != str(ctx.author.id):
|
||||
logger.warning(
|
||||
"User %s attempted to delete bot %r they don't own",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
await ctx.send("You can only delete your own custom bots.")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"User %s is authorized to delete bot %r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
# Delete the bot
|
||||
logger.debug("Deleting custom bot %r from database", bot_name)
|
||||
success = await asyncio.to_thread(app.manager.delete_custom_bot, bot_name)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Successfully deleted custom bot %r by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await asyncio.to_thread(invalidate_bot_cache, app)
|
||||
await ctx.send(f"Custom bot '{bot_name}' has been deleted.")
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to delete custom bot %r by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send("Failed to delete custom bot.")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Image commands: generation and editing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the image commands."""
|
||||
set_app(app)
|
||||
bot.command(name="doodlebob")(doodlebob)
|
||||
bot.command(name="retcon")(retcon)
|
||||
|
||||
|
||||
@commands.cooldown(rate=1, per=60, type=commands.BucketType.user)
|
||||
async def doodlebob(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Convert a message into an image using Doodlebob."""
|
||||
app = require_app()
|
||||
await app.image.generate(ctx, message=message)
|
||||
|
||||
|
||||
async def retcon(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Edit an attached image based on a text prompt."""
|
||||
app = require_app()
|
||||
await app.image.edit(ctx, message=message)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Speech commands: text-to-speech and the voice catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
from vibe_bot.config import VOICES_LIST
|
||||
from vibe_bot.textutil import split_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the speech commands."""
|
||||
set_app(app)
|
||||
bot.command(name="speak")(speak)
|
||||
bot.command(name="voices")(voices)
|
||||
|
||||
|
||||
async def voices(ctx: CommandsContext[Bot]) -> None:
|
||||
"""List all available TTS voices organized by category."""
|
||||
voice_list = "Available Voices:\n\n"
|
||||
for category, info in VOICES_LIST.items():
|
||||
voice_list += f"{category} ({info['language']}):\n"
|
||||
for v in info["voices"]:
|
||||
voice_list += f"- {v}\n"
|
||||
voice_list += "\n"
|
||||
voice_list += "Use `!speak <text> --voice <voice_name>` to choose a voice."
|
||||
|
||||
for chunk in split_message(voice_list):
|
||||
await ctx.send(chunk)
|
||||
|
||||
|
||||
@commands.cooldown(rate=3, per=30, type=commands.BucketType.user)
|
||||
async def speak(
|
||||
ctx: CommandsContext[Bot],
|
||||
*,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Have the bot speak the given text using Kokoro TTS, or have a custom bot speak.
|
||||
|
||||
Usage: !speak <text> --voice <voice_name> - plain text to speech
|
||||
Usage: !speak <bot_name> <text> --voice <voice_name> - have a custom bot respond and speak
|
||||
Example: !speak hello world
|
||||
Example: !speak hello world --voice af_bella
|
||||
Example: !speak alfred what time is it --voice am_puck
|
||||
"""
|
||||
app = require_app()
|
||||
await app.speech.speak(ctx, message=message)
|
||||
Reference in New Issue
Block a user