diff --git a/.python-version b/.python-version index 2c07333..902b2c9 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.11 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 068b5ea..5255900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,4 +11,4 @@ dependencies = [ "motor>=3.3.0", "python-dotenv>=1.0.0", "sqlalchemy>=2.0.0", -] +] \ No newline at end of file diff --git a/src/commands/__init__.py b/src/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/manage.py b/src/commands/manage.py new file mode 100644 index 0000000..4c2e979 --- /dev/null +++ b/src/commands/manage.py @@ -0,0 +1,43 @@ +import discord +from discord.ext import commands +from discord import app_commands +from database import get_ticket + +class ManageCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @app_commands.command(name="add", description="Add a user to the ticket") + async def add(self, interaction: discord.Interaction, user: discord.Member): + ticket = await get_ticket(interaction.channel_id) + if not ticket: + await interaction.response.send_message("❌ This is not a ticket channel.", ephemeral=True) + return + + if not interaction.user.guild_permissions.manage_channels: + await interaction.response.send_message("❌ You don't have permission to add users.", ephemeral=True) + return + + await interaction.channel.set_permissions(user, read_messages=True, send_messages=True) + await interaction.response.send_message(f"✅ {user.mention} has been added to the ticket.") + + @app_commands.command(name="remove", description="Remove a user from the ticket") + async def remove(self, interaction: discord.Interaction, user: discord.Member): + ticket = await get_ticket(interaction.channel_id) + if not ticket: + await interaction.response.send_message("❌ This is not a ticket channel.", ephemeral=True) + return + + if not interaction.user.guild_permissions.manage_channels: + await interaction.response.send_message("❌ You don't have permission to remove users.", ephemeral=True) + return + + if user.id == ticket.user_id: + await interaction.response.send_message("❌ You cannot remove the ticket owner.", ephemeral=True) + return + + await interaction.channel.set_permissions(user, overwrite=None) + await interaction.response.send_message(f"✅ {user.mention} has been removed from the ticket.") + +async def setup(bot): + await bot.add_cog(ManageCommands(bot)) \ No newline at end of file diff --git a/src/commands/panel.py b/src/commands/panel.py new file mode 100644 index 0000000..17b1089 --- /dev/null +++ b/src/commands/panel.py @@ -0,0 +1,42 @@ +import discord +from discord.ext import commands +from discord import app_commands +from database import set_config + +class PanelView(discord.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button(label="Create Ticket", style=discord.ButtonStyle.green, custom_id="create_ticket", emoji="🎫") + async def create_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): + from ticket_handler import handle_ticket_create + await handle_ticket_create(interaction) + +class PanelCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + bot.add_view(PanelView()) + + @app_commands.command(name="panel", description="Send the ticket creation panel") + @app_commands.default_permissions(administrator=True) + async def panel(self, interaction: discord.Interaction): + embed = discord.Embed( + title="🎫 Support Tickets", + description="Click the button below to create a support ticket.\nOur team will assist you shortly.", + color=discord.Color.blue() + ) + embed.set_footer(text="Ticket System") + + view = PanelView() + msg = await interaction.channel.send(embed=embed, view=view) + + await set_config( + interaction.guild_id, + panel_channel_id=interaction.channel_id, + panel_message_id=msg.id + ) + + await interaction.response.send_message("✅ Panel sent!", ephemeral=True) + +async def setup(bot): + await bot.add_cog(PanelCommands(bot)) \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..8964257 --- /dev/null +++ b/src/config.py @@ -0,0 +1,11 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +TOKEN = os.getenv("BOT_TOKEN") +DB_TYPE = os.getenv("DB_TYPE", "sqlite") +DB_URL = os.getenv("DB_URL", "sqlite+aiosqlite:///tickets.db") + +TICKET_CATEGORY_NAME = "Tickets" +TICKET_LOG_CHANNEL = "ticket-logs" \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..18d11e5 --- /dev/null +++ b/src/database.py @@ -0,0 +1,108 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import BigInteger, String, select +from typing import Optional +from config import DB_TYPE, DB_URL + +engine = None +SessionLocal = None + +class Base(DeclarativeBase): + pass + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(primary_key=True) + guild_id: Mapped[int] = mapped_column(BigInteger) + channel_id: Mapped[int] = mapped_column(BigInteger, unique=True) + user_id: Mapped[int] = mapped_column(BigInteger) + ticket_number: Mapped[int] + +class Config(Base): + __tablename__ = "config" + + id: Mapped[int] = mapped_column(primary_key=True) + guild_id: Mapped[int] = mapped_column(BigInteger, unique=True) + panel_channel_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + panel_message_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + category_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + +async def init_db(): + global engine, SessionLocal + + engine = create_async_engine(DB_URL, echo=False) + SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +def get_session(): + return SessionLocal() + +async def create_ticket(guild_id: int, channel_id: int, user_id: int) -> Ticket: + async with get_session() as session: + result = await session.execute( + select(Ticket).where(Ticket.guild_id == guild_id).order_by(Ticket.ticket_number.desc()) + ) + last_ticket = result.scalars().first() + ticket_num = (last_ticket.ticket_number + 1) if last_ticket else 1 + + ticket = Ticket( + guild_id=guild_id, + channel_id=channel_id, + user_id=user_id, + ticket_number=ticket_num + ) + session.add(ticket) + await session.commit() + await session.refresh(ticket) + + return type('TicketData', (), { + 'ticket_number': ticket.ticket_number, + 'channel_id': ticket.channel_id, + 'user_id': ticket.user_id, + 'guild_id': ticket.guild_id + })() + +async def get_ticket(channel_id: int) -> Optional[Ticket]: + async with get_session() as session: + result = await session.execute( + select(Ticket).where(Ticket.channel_id == channel_id) + ) + return result.scalars().first() + +async def delete_ticket(channel_id: int): + async with get_session() as session: + result = await session.execute( + select(Ticket).where(Ticket.channel_id == channel_id) + ) + ticket = result.scalars().first() + if ticket: + await session.delete(ticket) + await session.commit() + +async def get_config(guild_id: int) -> Optional[Config]: + async with get_session() as session: + result = await session.execute( + select(Config).where(Config.guild_id == guild_id) + ) + return result.scalars().first() + +async def set_config(guild_id: int, **kwargs) -> Config: + async with get_session() as session: + result = await session.execute( + select(Config).where(Config.guild_id == guild_id) + ) + config = result.scalars().first() + + if not config: + config = Config(guild_id=guild_id) + session.add(config) + + for key, value in kwargs.items(): + setattr(config, key, value) + + await session.commit() + await session.refresh(config) + return config \ No newline at end of file diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/ticket_handler.py b/src/handlers/ticket_handler.py new file mode 100644 index 0000000..5657228 --- /dev/null +++ b/src/handlers/ticket_handler.py @@ -0,0 +1,64 @@ +import discord +from database import create_ticket, get_ticket, get_config, get_session, Ticket +from sqlalchemy import select + +async def handle_ticket_create(interaction: discord.Interaction): + for channel in interaction.guild.channels: + if isinstance(channel, discord.TextChannel): + ticket = await get_ticket(channel.id) + if ticket and ticket.user_id == interaction.user.id: + await interaction.response.send_message( + f"❌ You already have an open ticket: {channel.mention}", + ephemeral=True + ) + return + + config = await get_config(interaction.guild_id) + category = None + + if config and config.category_id: + category = interaction.guild.get_channel(config.category_id) + + if not category: + category = discord.utils.get(interaction.guild.categories, name="Tickets") + if not category: + category = await interaction.guild.create_category("Tickets") + + async with get_session() as session: + result = await session.execute( + select(Ticket).where(Ticket.guild_id == interaction.guild_id).order_by(Ticket.ticket_number.desc()) + ) + last_ticket = result.scalars().first() + ticket_num = (last_ticket.ticket_number + 1) if last_ticket else 1 + + overwrites = { + interaction.guild.default_role: discord.PermissionOverwrite(read_messages=False), + interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True), + interaction.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True) + } + + channel = await interaction.guild.create_text_channel( + name=f"ticket-{ticket_num}", + category=category, + overwrites=overwrites + ) + + ticket = await create_ticket( + interaction.guild_id, + channel.id, + interaction.user.id + ) + + embed = discord.Embed( + title=f"Ticket #{ticket.ticket_number}", + description=f"Welcome {interaction.user.mention}!\nPlease describe your issue and a staff member will be with you shortly.", + color=discord.Color.green() + ) + + from views.ticket_view import TicketView + await channel.send(embed=embed, view=TicketView()) + + await interaction.response.send_message( + f"✅ Ticket created: {channel.mention}", + ephemeral=True + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..fb98646 --- /dev/null +++ b/src/main.py @@ -0,0 +1,28 @@ +import discord +from discord.ext import commands +import os +from pathlib import Path + +from database import init_db +from config import TOKEN, DB_TYPE, DB_URL + +intents = discord.Intents.default() +intents.message_content = True +intents.guilds = True +intents.members = True + +bot = commands.Bot(command_prefix="!", intents=intents) + +@bot.event +async def on_ready(): + await init_db() + + commands_path = Path("commands") + for file in commands_path.glob("*.py"): + if file.stem != "__init__": + await bot.load_extension(f"commands.{file.stem}") + + print(f"Bot ready as {bot.user}") + +if __name__ == "__main__": + bot.run(TOKEN) \ No newline at end of file diff --git a/src/views/__init__.py b/src/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/views/ticket_view.py b/src/views/ticket_view.py new file mode 100644 index 0000000..8374a36 --- /dev/null +++ b/src/views/ticket_view.py @@ -0,0 +1,42 @@ +import discord +from database import get_ticket, delete_ticket +import io + +class TicketView(discord.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button(label="Close", style=discord.ButtonStyle.red, custom_id="close_ticket", emoji="🔒") + async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): + ticket = await get_ticket(interaction.channel_id) + if not ticket: + await interaction.response.send_message("❌ This is not a ticket channel.", ephemeral=True) + return + + if not interaction.user.guild_permissions.manage_channels and interaction.user.id != ticket.user_id: + await interaction.response.send_message("❌ You don't have permission to close this ticket.", ephemeral=True) + return + + await interaction.response.send_message("🔒 Closing ticket and saving transcript...") + + messages = [] + async for msg in interaction.channel.history(limit=None, oldest_first=True): + timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") + messages.append(f"[{timestamp}] {msg.author}: {msg.content}") + + transcript = "\n".join(messages) + file = discord.File(io.BytesIO(transcript.encode()), filename=f"ticket-{ticket.ticket_number}.txt") + + user = interaction.guild.get_member(ticket.user_id) + if user: + try: + await user.send(f"Your ticket #{ticket.ticket_number} has been closed. Here's the transcript:", file=file) + except: + pass + + await delete_ticket(interaction.channel_id) + + await interaction.channel.delete() + +async def setup(bot): + bot.add_view(TicketView()) \ No newline at end of file