import discord from discord import app_commands from discord.ext import commands, tasks from datetime import datetime, timedelta, time as dt_time import json import logging import random import re import pytz import os from dotenv import load_dotenv load_dotenv() TOKEN = os.getenv('DISCORD_BOT_TOKEN') GUILD_IDS = [int(gid.strip()) for gid in os.getenv('GUILD_IDS', '').split(',') if gid.strip()] logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') log = logging.getLogger(__name__) intents = discord.Intents.default() intents.members = True bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents) TICKETS_FILE = 'tickets.json' TZ_UTC = pytz.UTC TZ_LOCAL = pytz.timezone('America/Edmonton') def load_tickets_data(): if os.path.exists(TICKETS_FILE): with open(TICKETS_FILE, 'r') as f: try: data = json.load(f) log.info("Loaded tickets data: %s", data) return data except json.JSONDecodeError as e: log.error("Error loading JSON data: %s", e) return {} return {} tickets_data = load_tickets_data() def save_tickets_data(): with open(TICKETS_FILE, 'w') as f: json.dump(tickets_data, f, indent=4) log.info("Saved tickets data: %s", tickets_data) def _format_ticket_expiry(ticket: dict) -> str: exp_utc = datetime.fromisoformat(ticket["expiration"]).astimezone(TZ_UTC) exp_local = exp_utc.astimezone(TZ_LOCAL) return f"- Expires on {exp_utc.strftime('%Y-%m-%d %H:%M:%S')} UTC / {exp_local.strftime('%Y-%m-%d %H:%M:%S')} {exp_local.strftime('%Z')}" async def ticket_type_autocomplete(interaction: discord.Interaction, current: str): ticket_types = ["normal", "golden"] return [ app_commands.Choice(name=t, value=t) for t in ticket_types if t.startswith(current.lower()) ] class TP(app_commands.Group): pass class TPA(app_commands.Group): pass tp = TP(name="tp", description="Tickets Please commands") tpa = TPA(name="tpa", description="Tickets Please admin commands", default_permissions=discord.Permissions(administrator=True)) @tpa.command(name="assign_ticket", description="Assign a ticket to a member") @app_commands.describe( member="The member to assign the ticket to" ) async def assign_ticket(interaction: discord.Interaction, member: discord.Member): member_id = str(member.id) if member_id not in tickets_data: tickets_data[member_id] = {"tickets": []} expiration_date = datetime.now(TZ_UTC) + timedelta(days=365) tickets_data[member_id]["tickets"].append({ "type": "normal", "expiration": expiration_date.isoformat() }) save_tickets_data() await interaction.response.send_message( f"Assigned a ticket to {member.mention}, which expires on {expiration_date.strftime('%Y-%m-%d %H:%M:%S')} UTC.", ephemeral=True ) @tpa.command(name="redeem_ticket", description="Redeem a ticket for a member") @app_commands.describe( member="The member to redeem the ticket for", ticket_type="The type of ticket to redeem" ) @app_commands.autocomplete(ticket_type=ticket_type_autocomplete) @app_commands.default_permissions(administrator=True) async def redeem_ticket(interaction: discord.Interaction, member: discord.Member, ticket_type: str): if ticket_type not in ["normal", "golden"]: await interaction.response.send_message("Invalid ticket type. Use 'normal' or 'golden'.", ephemeral=True) return member_id = str(member.id) if member_id not in tickets_data or not tickets_data[member_id]["tickets"]: await interaction.response.send_message(f"{member.mention} does not have any tickets.", ephemeral=True) return tickets = tickets_data[member_id]["tickets"] ticket_to_remove = next((t for t in tickets if t["type"] == ticket_type), None) if ticket_to_remove is None: await interaction.response.send_message(f"{member.mention} does not have any {ticket_type} tickets to redeem.", ephemeral=True) return tickets.remove(ticket_to_remove) save_tickets_data() await interaction.response.send_message(f"Redeemed a {ticket_type} ticket for {member.mention}.", ephemeral=True) @tpa.command(name="check_ticket", description="Check a member's tickets") @app_commands.describe(member="The member to check tickets for") @app_commands.default_permissions(administrator=True) async def check_ticket(interaction: discord.Interaction, member: discord.Member): member_id = str(member.id) if member_id not in tickets_data or not tickets_data[member_id]["tickets"]: await interaction.response.send_message(f"{member.mention} does not have any tickets.", ephemeral=True) return ticket_info = tickets_data[member_id]["tickets"] normal_tickets = [t for t in ticket_info if t["type"] == "normal"] golden_tickets = [t for t in ticket_info if t["type"] == "golden"] lines = [] if normal_tickets: lines.append(f"{member.mention} has {len(normal_tickets)} normal ticket(s):") lines.extend(_format_ticket_expiry(t) for t in normal_tickets) if golden_tickets: lines.append(f"{member.mention} has {len(golden_tickets)} golden ticket(s).") await interaction.response.send_message("\n".join(lines), ephemeral=True) @tp.command(name="tickets", description="Check your own tickets") async def tickets(interaction: discord.Interaction): member_id = str(interaction.user.id) if member_id not in tickets_data or not tickets_data[member_id]["tickets"]: await interaction.response.send_message("You do not have any tickets.", ephemeral=True) return ticket_info = tickets_data[member_id]["tickets"] normal_tickets = [t for t in ticket_info if t["type"] == "normal"] golden_tickets = [t for t in ticket_info if t["type"] == "golden"] lines = [] if normal_tickets: lines.append(f"You have {len(normal_tickets)} normal ticket(s):") lines.extend(_format_ticket_expiry(t) for t in normal_tickets) if golden_tickets: lines.append(f"You have {len(golden_tickets)} golden ticket(s).") await interaction.response.send_message("\n".join(lines), ephemeral=True) def parse_and_roll(notation: str): notation = notation.replace(' ', '').lower() matches = re.findall(r'([+-]?)(adv|dis|\d*d\d+|\d+)', notation) if not matches: return None, None if len(matches) > 20: return None, "Too many terms (max 20)." lines = [] total = 0 for sign, term in matches: multiplier = -1 if sign == '-' else 1 prefix = '-' if multiplier == -1 else ('+' if lines else '') if term in ('adv', 'dis'): rolls = [random.randint(1, 20), random.randint(1, 20)] kept = max(rolls) if term == 'adv' else min(rolls) subtotal = kept * multiplier rolls_str = ', '.join(f"**{r}**" if r == kept else f"~~{r}~~" for r in rolls) lines.append(f"`{prefix}{term}:` [{rolls_str}] = **{subtotal}**") total += subtotal elif 'd' in term: parts = term.split('d') num = int(parts[0]) if parts[0] else 1 sides = int(parts[1]) if num < 1 or num > 100: return None, f"Number of dice must be between 1 and 100 (got {num})." if sides < 2 or sides > 1000: return None, f"Die sides must be between 2 and 1000 (got {sides})." rolls = [random.randint(1, sides) for _ in range(num)] subtotal = sum(rolls) * multiplier lines.append(f"`{prefix}{term}:` [{', '.join(str(r) for r in rolls)}] = **{subtotal}**") total += subtotal else: flat = int(term) * multiplier lines.append(f"`{prefix}{flat}`") total += flat return lines, total async def handle_roll(interaction: discord.Interaction, notation: str): lines, result = parse_and_roll(notation) if lines is None: await interaction.response.send_message( result or "Invalid dice notation. Try something like `2d20+1d4+3`.", ephemeral=True ) return lines.append(f"{'─' * 20}\n**Total: {result}**") await interaction.response.send_message("\n".join(lines)) @bot.tree.command(name="roll", description="Roll dice using dice notation (e.g. 2d20+1d4+3)") @app_commands.describe(dice="Dice notation, e.g. 2d20+1d4+3") async def roll_cmd(interaction: discord.Interaction, dice: str): await handle_roll(interaction, dice) @bot.tree.command(name="r", description="Roll dice using dice notation (e.g. 2d20+1d4+3)") @app_commands.describe(dice="Dice notation, e.g. 2d20+1d4+3") async def r_cmd(interaction: discord.Interaction, dice: str): await handle_roll(interaction, dice) @tasks.loop(time=dt_time(hour=0, minute=0, tzinfo=TZ_UTC)) async def check_expired_tickets(): now = datetime.now(TZ_UTC) to_remove = [] for member_id, ticket_info in tickets_data.items(): tickets_data[member_id]["tickets"] = [ ticket for ticket in ticket_info["tickets"] if ticket["type"] == "golden" or now < datetime.fromisoformat(ticket["expiration"]).astimezone(TZ_UTC) ] if not tickets_data[member_id]["tickets"]: to_remove.append(member_id) for member_id in to_remove: log.info("Removing expired tickets for user ID: %s", member_id) del tickets_data[member_id] save_tickets_data() @bot.event async def setup_hook(): bot.tree.add_command(tp) bot.tree.add_command(tpa) for guild_id in GUILD_IDS: guild = discord.Object(id=guild_id) bot.tree.copy_global_to(guild=guild) await bot.tree.sync(guild=guild) log.info("Synced commands to guild %s", guild_id) @bot.event async def on_ready(): log.info("Logged in as %s", bot.user.name) check_expired_tickets.start() bot.run(TOKEN)