This documentation covers the custom Discord bot built in Python using discord.py with slash command support via app_commands. The bot has grown from a simple status-rotating bot into a full-featured server utility, including an OpenAI integration for answering questions and a complete D&D Party Inventory System with interactive Discord UI components.
The bot uses environment variables for sensitive credentials — the Discord token and OpenAI API key are loaded via os.getenv() and never hardcoded.
| Command | Type | Description |
|---|---|---|
/inv | Slash | Opens the D&D Party Inventory panel |
/sb | Slash | Sends a random image from the bot's images folder |
/bx | Slash | Posts the bxbrian YouTube link |
/dudu | Slash | Posts the dulonkk YouTube link |
/sike | Slash | Sends a playful message |
!info | Prefix | Displays the current server name |
!poll | Prefix | Creates a reaction-based poll |
/inv)
The most substantial feature of the bot. Running /inv posts a persistent embed panel in the channel that anyone in the server can interact with using buttons and dropdown menus — no additional commands needed. Inventory data is stored in JSON files on disk, sharded by guild ID so each server has its own isolated inventory.
| Button | Description |
|---|---|
| 📖 Add from Compendium | Search the D&D compendium and add an official item to inventory |
| ✏️ Add Custom Item | Add a homebrew or custom item with name, quantity, cost, weight, and description |
| 🗑️ Remove Item | Select an item from inventory and remove some or all of it |
| 📋 Item Details | View full stats and description for any item in the inventory |
| 🪙 Gold | Manage party gold, personal gold, and transfers between the two |
| ✨ Spell Lookup | Search the compendium for spells and view full spell details |
| 🔄 Refresh | Refresh the embed to show the latest inventory state |
The bot loads item and spell data at startup by parsing a local compendium.xml file using Python's xml.etree.ElementTree. The parser also sanitizes bare & characters that would otherwise cause XML parse errors. Search results are paginated in groups of 25 using Discord select menus and next/previous buttons.
def load_compendium() -> list[dict]:
import re
items = []
with open(COMPENDIUM_PATH, "r", encoding="utf-8", errors="ignore") as f:
raw = f.read()
# Fix bare & that aren't part of an XML entity
raw = re.sub(r"&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)", "&", raw)
root = ET.fromstring(raw)
for item in root.findall(".//item"):
name = (item.findtext("name") or "").strip()
if name:
items.append({ "name": name, "type": item.findtext("type") or "—", ... })
return sorted(items, key=lambda x: x["name"])
The inventory tracks two separate gold pools — a shared party gold pool and individual player gold wallets stored per Discord user ID. The panel displays both along with a grand total. Players can add/spend their own gold, modify party gold (if they have permission), or transfer gold between themselves and the party pool in either direction.
Each guild's inventory is saved to inventories/<guild_id>/party.json. The file stores items (with quantity, custom flag, and optional metadata), party gold, and a per-user gold map. Files are created automatically on first use.
# Example party.json structure
{
"items": {
"Longsword": { "qty": 2, "custom": false },
"Bag of Holding": { "qty": 1, "custom": false },
"Mystery Potion": { "qty": 3, "custom": true, "description": "Glows faintly blue." }
},
"gold": 450,
"player_gold": {
"123456789": 120,
"987654321": 75
}
}
The bot automatically rotates its status every 5 seconds using a background task loop. No user interaction is needed — it starts automatically when the bot comes online.
@tasks.loop(seconds=5)
async def change_status():
await bot.change_presence(activity=discord.Game(random.choice(statuses)))
/sb — Random Image
Picks a random file from the bot's local images directory and sends it in the channel. If the folder is empty, the bot replies with an ephemeral error message only visible to the user.
@bot.tree.command(name="sb", description="Send a random image from the 'images' folder.")
async def send_image(interaction: discord.Interaction):
image_dir = 'images'
images = os.listdir(image_dir)
if not images:
await interaction.response.send_message('No images found.', ephemeral=True)
return
image_file = random.choice(images)
image_path = os.path.join(image_dir, image_file)
await interaction.response.send_message(file=discord.File(image_path))
!poll — Reaction PollCreates a poll with ✅/❌ reactions. Supports a plain yes/no poll or two custom options. The command also purges the user's original message to keep the channel clean.
Usage:
!poll "Is this bot useful?"!poll "Best game?" "Minecraft" "Terraria"!poll "Go out?" "Yes" — uses "No" as the second option automatically@bot.command()
async def poll(ctx, question, option1=None, option2=None):
await ctx.channel.purge(limit=1)
if option1 is None and option2 is None:
message = await ctx.send(f"```New poll: \n{question}```\n**✅ = Yes**\n**❌ = No**")
elif option1 is not None and option2 is None:
message = await ctx.send(f"```New poll: \n{question}```\n**✅ = {option1}**\n**❌ = No**")
elif option1 is None and option2 is not None:
message = await ctx.send(f"```New poll: \n{question}```\n**✅ = Yes**\n**❌ = {option2}**")
else:
message = await ctx.send(f"```New poll: \n{question}```\n**✅ = {option1}**\n**❌ = {option2}**")
await message.add_reaction('❌')
await message.add_reaction('✅')
discord.py with app_commands supportopenai Python packagecompendium.xml file in the same directory as the bot for compendium/spell featuresimages/ folder for the /sb commandDISCORD_TOKEN and OPENAI_API_KEY/) and prefix commands (!). Slash commands are synced to Discord on startup.timeout=None on its View so buttons remain active indefinitely without expiring.compendium.xml./bx, /dudu, /sike) are server-specific fun commands and may reference inside jokes.