tickets-please/bot.py

273 lines
9.8 KiB
Python

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)