454 lines
15 KiB
Python
454 lines
15 KiB
Python
import discord
|
|
from discord import app_commands
|
|
from discord.ext import commands
|
|
import os
|
|
from dotenv import load_dotenv
|
|
from datetime import datetime
|
|
import json
|
|
import mysql.connector
|
|
import aiohttp
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
TOKEN = os.getenv('DISCORD_TOKEN')
|
|
|
|
# Bot setup
|
|
intents = discord.Intents.default()
|
|
intents.message_content = True
|
|
intents.guilds = True
|
|
|
|
bot = commands.Bot(command_prefix='!', intents=intents)
|
|
|
|
# MySQL connection setup
|
|
MYSQL_HOST = os.getenv('MYSQL_HOST', 'localhost')
|
|
MYSQL_PORT = int(os.getenv('MYSQL_PORT', '3306'))
|
|
MYSQL_USER = os.getenv('MYSQL_USER', 'root')
|
|
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
|
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'tickets')
|
|
|
|
def get_db_connection():
|
|
return mysql.connector.connect(
|
|
host=MYSQL_HOST,
|
|
port=MYSQL_PORT,
|
|
user=MYSQL_USER,
|
|
password=MYSQL_PASSWORD,
|
|
database=MYSQL_DATABASE
|
|
)
|
|
|
|
def init_db():
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS tickets (
|
|
ticket_id VARCHAR(32) PRIMARY KEY,
|
|
message_id BIGINT,
|
|
channel_id BIGINT,
|
|
title VARCHAR(255),
|
|
project VARCHAR(255),
|
|
status VARCHAR(32),
|
|
creator BIGINT,
|
|
created_at VARCHAR(32),
|
|
reference_message_id VARCHAR(32),
|
|
archived BOOLEAN DEFAULT FALSE
|
|
)
|
|
''')
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS server_settings (
|
|
guild_id BIGINT PRIMARY KEY,
|
|
active_channel_id BIGINT,
|
|
archive_channel_id BIGINT
|
|
)
|
|
''')
|
|
conn.commit()
|
|
cursor.close()
|
|
conn.close()
|
|
|
|
# Server settings DB functions
|
|
|
|
def save_server_settings(guild_id, active_channel_id, archive_channel_id):
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
REPLACE INTO server_settings (guild_id, active_channel_id, archive_channel_id)
|
|
VALUES (%s, %s, %s)
|
|
''', (guild_id, active_channel_id, archive_channel_id))
|
|
conn.commit()
|
|
cursor.close()
|
|
conn.close()
|
|
|
|
def load_server_settings(guild_id):
|
|
conn = get_db_connection()
|
|
cursor = conn.cursor(dictionary=True)
|
|
cursor.execute('SELECT * FROM server_settings WHERE guild_id = %s', (guild_id,))
|
|
result = cursor.fetchone()
|
|
cursor.close()
|
|
conn.close()
|
|
return result
|
|
|
|
# Ticket storage (in production, use a database)
|
|
tickets = {}
|
|
ticket_counter = 1
|
|
|
|
# Ticket statuses
|
|
class TicketStatus:
|
|
PENDING = "⏳ Pending"
|
|
IN_PROGRESS = "🔄 In Progress"
|
|
COMPLETED = "✅ Completed"
|
|
CANCELLED = "❌ Cancelled"
|
|
|
|
# Dynamically fetch project names from devanturas.net/versions
|
|
PROJECTS_URL = "https://devanturas.net/versions"
|
|
_project_cache = []
|
|
|
|
async def fetch_project_names():
|
|
global _project_cache
|
|
if _project_cache:
|
|
return _project_cache
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(PROJECTS_URL) as resp:
|
|
data = await resp.json()
|
|
names = [v["name"] for v in data.values() if "name" in v]
|
|
_project_cache = names
|
|
return names
|
|
except Exception:
|
|
return []
|
|
|
|
@bot.event
|
|
async def on_ready():
|
|
print(f'{bot.user} has connected to Discord!')
|
|
init_db()
|
|
# Preload project names
|
|
try:
|
|
import asyncio
|
|
asyncio.create_task(fetch_project_names())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
synced = await bot.tree.sync()
|
|
print(f"Synced {len(synced)} command(s)")
|
|
except Exception as e:
|
|
print(f"Error syncing commands: {e}")
|
|
|
|
@bot.tree.command(name="setup", description="Setup the bot channels")
|
|
@app_commands.describe(
|
|
active_channel="The channel for active tickets",
|
|
archive_channel="The channel for archived tickets"
|
|
)
|
|
async def setup(
|
|
interaction: discord.Interaction,
|
|
active_channel: discord.TextChannel,
|
|
archive_channel: discord.TextChannel
|
|
):
|
|
guild_id = interaction.guild.id if interaction.guild else None
|
|
if not guild_id:
|
|
await interaction.response.send_message("❌ This command must be used in a server.", ephemeral=True)
|
|
return
|
|
save_server_settings(guild_id, active_channel.id, archive_channel.id)
|
|
await interaction.response.send_message(
|
|
f"✅ Configuration saved!\n"
|
|
f"Active tickets: {active_channel.mention}\n"
|
|
f"Archive: {archive_channel.mention}",
|
|
ephemeral=True
|
|
)
|
|
|
|
@bot.tree.command(name="ticket", description="Create a new project update ticket")
|
|
@app_commands.describe(
|
|
project_name="Select the project for this ticket",
|
|
title="Brief title for this update",
|
|
message_id="Optional: Discord message ID to reference"
|
|
)
|
|
async def create_ticket(
|
|
interaction: discord.Interaction,
|
|
project_name: str,
|
|
title: str,
|
|
message_id: str = None
|
|
):
|
|
global ticket_counter
|
|
valid_projects = await fetch_project_names()
|
|
if project_name not in valid_projects:
|
|
await interaction.response.send_message(
|
|
f"❌ Invalid project name. Please use one of: {', '.join(valid_projects)}",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
guild_id = interaction.guild.id if interaction.guild else None
|
|
settings = load_server_settings(guild_id)
|
|
if not settings or not settings.get("active_channel_id"):
|
|
await interaction.response.send_message(
|
|
"❌ Bot not configured! Please use `/setup` first.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
active_channel = bot.get_channel(settings["active_channel_id"])
|
|
ticket_id = f"TICKET-{ticket_counter:04d}"
|
|
ticket_counter += 1
|
|
referenced_message_content = None
|
|
referenced_message_author = None
|
|
if message_id:
|
|
try:
|
|
ref_message = await active_channel.fetch_message(int(message_id))
|
|
referenced_message_content = ref_message.content
|
|
referenced_message_author = ref_message.author.mention
|
|
except Exception:
|
|
referenced_message_content = None
|
|
referenced_message_author = None
|
|
embed = discord.Embed(
|
|
title=f"🎫 {ticket_id}: {title}",
|
|
description=f"**Project Update Request**",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.utcnow()
|
|
)
|
|
embed.add_field(name="📋 Project", value=project_name, inline=True)
|
|
embed.add_field(name="📊 Status", value=TicketStatus.PENDING, inline=True)
|
|
embed.add_field(name="👤 Created By", value=interaction.user.mention, inline=True)
|
|
if message_id:
|
|
embed.add_field(name="🔗 Message ID", value=f"`{message_id}`", inline=False)
|
|
if referenced_message_content:
|
|
embed.add_field(name="💬 Request Message", value=referenced_message_content, inline=False)
|
|
if referenced_message_author:
|
|
embed.add_field(name="🙋 Request By", value=referenced_message_author, inline=True)
|
|
embed.add_field(
|
|
name="🌐 Project Links",
|
|
value=f"[All Projects](https://devanturas.net/versions) | [Project Page](https://devanturas.net/projects/{project_name})",
|
|
inline=False
|
|
)
|
|
embed.set_footer(text=f"Ticket ID: {ticket_id}")
|
|
ticket_message = await active_channel.send(embed=embed)
|
|
ticket = {
|
|
"message_id": ticket_message.id,
|
|
"channel_id": active_channel.id,
|
|
"title": title,
|
|
"project": project_name,
|
|
"status": TicketStatus.PENDING,
|
|
"creator": interaction.user.id,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
"reference_message_id": message_id,
|
|
"referenced_message_content": referenced_message_content,
|
|
"referenced_message_author": referenced_message_author
|
|
}
|
|
save_ticket(ticket_id, ticket)
|
|
await interaction.response.send_message(
|
|
f"✅ Ticket created successfully!\n"
|
|
f"**Ticket ID:** `{ticket_id}`\n"
|
|
f"**Channel:** {active_channel.mention}\n"
|
|
f"Use `/status {ticket_id}` to update the status.",
|
|
ephemeral=True
|
|
)
|
|
|
|
@bot.tree.command(name="status", description="Update ticket status")
|
|
@app_commands.describe(
|
|
ticket_id="The ticket ID (e.g., TICKET-0001)",
|
|
new_status="The new status for the ticket"
|
|
)
|
|
@app_commands.choices(new_status=[
|
|
app_commands.Choice(name="⏳ Pending", value="pending"),
|
|
app_commands.Choice(name="🔄 In Progress", value="in_progress"),
|
|
app_commands.Choice(name="✅ Completed", value="completed"),
|
|
app_commands.Choice(name="❌ Cancelled", value="cancelled"),
|
|
])
|
|
async def update_status(
|
|
interaction: discord.Interaction,
|
|
ticket_id: str,
|
|
new_status: str
|
|
):
|
|
"""Update the status of a ticket"""
|
|
|
|
# Convert to uppercase for consistency
|
|
ticket_id = ticket_id.upper()
|
|
|
|
ticket = load_ticket(ticket_id)
|
|
if not ticket:
|
|
await interaction.response.send_message(
|
|
f"❌ Ticket `{ticket_id}` not found!",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Map status choice to display text
|
|
status_map = {
|
|
"pending": TicketStatus.PENDING,
|
|
"in_progress": TicketStatus.IN_PROGRESS,
|
|
"completed": TicketStatus.COMPLETED,
|
|
"cancelled": TicketStatus.CANCELLED
|
|
}
|
|
|
|
old_status = ticket["status"]
|
|
new_status_text = status_map[new_status]
|
|
ticket["status"] = new_status_text
|
|
save_ticket(ticket_id, ticket)
|
|
|
|
# Get the ticket message
|
|
channel = bot.get_channel(ticket["channel_id"])
|
|
if not channel:
|
|
await interaction.response.send_message(
|
|
"❌ Channel not found!",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
try:
|
|
message = await channel.fetch_message(ticket["message_id"])
|
|
embed = message.embeds[0]
|
|
|
|
# Update the status field
|
|
for i, field in enumerate(embed.fields):
|
|
if field.name == "📊 Status":
|
|
embed.set_field_at(i, name="📊 Status", value=new_status_text, inline=True)
|
|
break
|
|
|
|
# Change color based on status
|
|
if new_status == "completed":
|
|
embed.color = discord.Color.green()
|
|
elif new_status == "in_progress":
|
|
embed.color = discord.Color.orange()
|
|
elif new_status == "cancelled":
|
|
embed.color = discord.Color.red()
|
|
|
|
# Add status update log
|
|
embed.add_field(
|
|
name=f"📝 Status Updated",
|
|
value=f"{interaction.user.mention} changed status from {old_status} to {new_status_text}",
|
|
inline=False
|
|
)
|
|
|
|
await message.edit(embed=embed)
|
|
|
|
# If completed, archive the ticket
|
|
if new_status == "completed":
|
|
await archive_ticket(interaction, ticket_id, message, channel)
|
|
else:
|
|
await interaction.response.send_message(
|
|
f"✅ Ticket `{ticket_id}` status updated to {new_status_text}",
|
|
ephemeral=True
|
|
)
|
|
|
|
except discord.NotFound:
|
|
await interaction.response.send_message(
|
|
"❌ Ticket message not found!",
|
|
ephemeral=True
|
|
)
|
|
except Exception as e:
|
|
await interaction.response.send_message(
|
|
f"❌ Error updating ticket: {str(e)}",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def archive_ticket(interaction, ticket_id, message, current_channel):
|
|
guild_id = interaction.guild.id if interaction.guild else None
|
|
settings = load_server_settings(guild_id)
|
|
archive_channel_id = settings.get("archive_channel_id") if settings else None
|
|
if not archive_channel_id:
|
|
await interaction.response.send_message(
|
|
"⚠️ Status updated, but no archive channel configured. Use `/setup` to configure.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
archive_channel = bot.get_channel(archive_channel_id)
|
|
if not archive_channel:
|
|
await interaction.response.send_message(
|
|
"⚠️ Archive channel not found!",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Copy message to archive
|
|
embed = message.embeds[0]
|
|
embed.add_field(
|
|
name="📦 Archived",
|
|
value=f"Moved to archive on {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}",
|
|
inline=False
|
|
)
|
|
|
|
await archive_channel.send(embed=embed)
|
|
|
|
# Delete from active channel
|
|
await message.delete()
|
|
|
|
# Update ticket storage
|
|
ticket = load_ticket(ticket_id)
|
|
ticket["channel_id"] = archive_channel.id
|
|
ticket["archived"] = True
|
|
save_ticket(ticket_id, ticket)
|
|
|
|
await interaction.response.send_message(
|
|
f"✅ Ticket `{ticket_id}` marked as completed and archived to {archive_channel.mention}",
|
|
ephemeral=True
|
|
)
|
|
|
|
@bot.tree.command(name="list", description="List all active tickets")
|
|
async def list_tickets(interaction: discord.Interaction):
|
|
"""List all active tickets"""
|
|
|
|
active_tickets = [
|
|
(t["ticket_id"], t) for t in load_all_tickets(archived=False)
|
|
]
|
|
|
|
if not active_tickets:
|
|
await interaction.response.send_message(
|
|
"📭 No active tickets found.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
embed = discord.Embed(
|
|
title="🎫 Active Tickets",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.utcnow()
|
|
)
|
|
|
|
for ticket_id, ticket in active_tickets[:25]: # Discord limit
|
|
embed.add_field(
|
|
name=f"{ticket_id}: {ticket['title']}",
|
|
value=f"Project: {ticket['project']}\nStatus: {ticket['status']}",
|
|
inline=False
|
|
)
|
|
|
|
if len(active_tickets) > 25:
|
|
embed.set_footer(text=f"Showing 25 of {len(active_tickets)} tickets")
|
|
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
|
|
@bot.tree.command(name="info", description="Get detailed information about a ticket")
|
|
@app_commands.describe(ticket_id="The ticket ID (e.g., TICKET-0001)")
|
|
async def ticket_info(interaction: discord.Interaction, ticket_id: str):
|
|
"""Get detailed information about a specific ticket"""
|
|
|
|
ticket_id = ticket_id.upper()
|
|
|
|
ticket = load_ticket(ticket_id)
|
|
if not ticket:
|
|
await interaction.response.send_message(
|
|
f"❌ Ticket `{ticket_id}` not found!",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
creator = await bot.fetch_user(ticket["creator"])
|
|
|
|
embed = discord.Embed(
|
|
title=f"🎫 {ticket_id} Details",
|
|
description=ticket["title"],
|
|
color=discord.Color.blue()
|
|
)
|
|
|
|
embed.add_field(name="📋 Project", value=ticket["project"], inline=True)
|
|
embed.add_field(name="📊 Status", value=ticket["status"], inline=True)
|
|
embed.add_field(name="👤 Creator", value=creator.mention, inline=True)
|
|
embed.add_field(name="📅 Created", value=ticket["created_at"][:10], inline=True)
|
|
embed.add_field(name="🔗 Reference Message", value=f"`{ticket['reference_message_id']}`", inline=False)
|
|
embed.add_field(
|
|
name="📦 Archived",
|
|
value="Yes" if ticket.get("archived") else "No",
|
|
inline=True
|
|
)
|
|
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
|
|
# Run the bot
|
|
if __name__ == "__main__":
|
|
if not TOKEN:
|
|
print("ERROR: DISCORD_TOKEN not found in .env file!")
|
|
else:
|
|
bot.run(TOKEN)
|