From a2f1e00770d22173663fd98a42f3eca22a5daa7f Mon Sep 17 00:00:00 2001 From: Ryuga Date: Tue, 21 Apr 2026 20:24:19 +0530 Subject: [PATCH 1/3] chore: improve unban command --- bot/cogs/moderation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index de6cb4f..36f83dd 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -326,6 +326,19 @@ async def unban(self, interaction: discord.Interaction, user_id: str, reason: st ) user = await self.bot.fetch_user(user_id_int) await interaction.guild.unban(user=user, reason=reason) + try: + await user.send( + embed=info( + "You have been unbanned in Tortoise Community\n" + "Please use the below link to rejoin the server\n" + f"👉 [Invite Link]({constants.server_link}) ", + self.bot.user, + "Ban Lifted!", + "Welcome back to Tortoise Programming Community!", + ) + ) + except Exception as e: + pass await interaction.followup.send(embed=success(f"{user} successfully unbanned."), ephemeral=True) From c988a9623a26685354051e39fa3cf185a9393ef2 Mon Sep 17 00:00:00 2001 From: Ryuga Date: Wed, 22 Apr 2026 15:48:29 +0530 Subject: [PATCH 2/3] Add giveaway system --- bot/bot.py | 5 +- bot/cogs/giveaway.py | 258 +++++++++++++++++++++++++++++++++++++++++++ bot/manager.py | 112 +++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 bot/cogs/giveaway.py diff --git a/bot/bot.py b/bot/bot.py index 9728e27..ef44c90 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -15,7 +15,7 @@ from bot.api_client import TortoiseAPI from bot.constants import error_log_channel_id, system_log_channel_id, github_repo_link from bot.manager import ( - Database, ProgressionManager, AFKManager, PointsManager, RetentionManager, TeamManager + Database, ProgressionManager, AFKManager, PointsManager, RetentionManager, TeamManager, GiveawayManager ) from bot.utils.embed_handler import simple_embed from bot.utils.error_handler import TortoiseCommandTree @@ -74,6 +74,7 @@ def __init__(self, prefix="t.", *args, **kwargs): self.afk_manager = None self.retention_manager = None self.team_manager = None + self.giveaway_manager = None self._sys_log_channel = None @property @@ -138,12 +139,14 @@ async def setup_hook(self): self.points_manager = PointsManager(self.db) self.retention_manager = RetentionManager(self.db) self.team_manager = TeamManager(self.db) + self.giveaway_manager = GiveawayManager(self.db) await self.progression_manager.setup() await self.afk_manager.setup() await self.points_manager.setup() await self.retention_manager.setup() await self.team_manager.setup() + await self.giveaway_manager.setup() await self.load_extensions() # await self.reload_tortoise_meta_cache() diff --git a/bot/cogs/giveaway.py b/bot/cogs/giveaway.py new file mode 100644 index 0000000..966ca6f --- /dev/null +++ b/bot/cogs/giveaway.py @@ -0,0 +1,258 @@ +from __future__ import annotations +import asyncio +import random +import json +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any, Optional + +import discord +from discord.ext import commands +from discord import app_commands + +from bot.utils.embed_handler import info, warning, success +from bot.utils.checks import check_if_tortoise_staff + + +class QuestionModal(discord.ui.Modal, title='Add Question'): + question = discord.ui.TextInput(label='Question', max_length=200) + expected = discord.ui.TextInput(label='Expected Answer (yes/no)', max_length=3, placeholder='yes') + + def __init__(self, parent: SetupView): + super().__init__() + self.parent = parent + + async def on_submit(self, interaction: discord.Interaction): + ans = self.expected.value.lower().strip() + if ans not in ('yes', 'no'): + await interaction.response.send_message('Expected answer must be "yes" or "no".', ephemeral=True) + return + + self.parent.questions.append({'question': self.question.value, 'answer': ans}) + await interaction.response.send_message(f'Question added. Total: {len(self.parent.questions)}', ephemeral=True) + + +class SetupView(discord.ui.View): + def __init__(self, cog: Giveaway, data: dict): + super().__init__(timeout=600) + self.cog = cog + self.data = data + self.questions: List[Dict[str, str]] = [] + + @discord.ui.button(label='Add Question', style=discord.ButtonStyle.blurple) + async def add_question(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(QuestionModal(self)) + + @discord.ui.button(label='Publish Giveaway', style=discord.ButtonStyle.green) + async def publish(self, interaction: discord.Interaction, button: discord.ui.Button): + end_time = datetime.now(timezone.utc) + timedelta(minutes=self.data['duration']) + timestamp = int(end_time.timestamp()) + + description_body = ( + f"**{self.data['name']}**\n" + f"{self.data['description']}\n\n" + f"**Prizes**\n{self.data['prizes']}\n\n" + f"Ends: " + ) + + embed = info(description_body, interaction.client.user, '🎉 Giveaway Started') + msg = await interaction.channel.send(embed=embed, view=JoinView(self.cog)) + + # Persist to DB + await self.cog.manager.create_giveaway( + msg.id, interaction.guild.id, interaction.channel.id, + interaction.user.id, self.data['name'], self.data['description'], + self.data['prizes'], json.dumps(self.questions), + self.data['winners'], end_time + ) + + # Start the background timer + self.cog.tasks[msg.id] = asyncio.create_task(self.cog.finish_task(msg.id, end_time)) + + await interaction.response.edit_message(content='✅ Giveaway published successfully.', view=None) + + +class CreateModal(discord.ui.Modal, title='Create Giveaway'): + name = discord.ui.TextInput(label='Name', placeholder='Epic Nitro Giveaway') + description = discord.ui.TextInput(label='Description', style=discord.TextStyle.paragraph, required=False) + prizes = discord.ui.TextInput(label='Prizes', style=discord.TextStyle.paragraph, placeholder='1x Discord Nitro') + + def __init__(self, cog: Giveaway, duration: int, winners: int): + super().__init__() + self.cog = cog + self.duration = duration + self.winners = winners + + async def on_submit(self, interaction: discord.Interaction): + data = { + 'name': self.name.value, + 'description': self.description.value, + 'prizes': self.prizes.value, + 'duration': self.duration, + 'winners': self.winners + } + await interaction.response.send_message( + 'Giveaway initialized. Add optional qualification questions below.', + view=SetupView(self.cog, data), + ephemeral=True + ) + + +class Questionnaire(discord.ui.View): + def __init__(self, cog: Giveaway, row: dict, user_id: int): + super().__init__(timeout=300) + self.cog = cog + self.row = row + self.user_id = user_id + self.current_index = 0 + self.questions = row['questions'] + + async def _update_question(self, interaction: discord.Interaction): + q_data = self.questions[self.current_index] + embed = info(q_data['question'], self.cog.bot.user, f'Question {self.current_index + 1}/{len(self.questions)}') + await interaction.response.edit_message(embed=embed, view=self) + + async def handle_answer(self, interaction: discord.Interaction, ans: str): + if interaction.user.id != self.user_id: + return await interaction.response.send_message('This is not your session.', ephemeral=True) + + # Validate answer + if ans != self.questions[self.current_index]['answer']: + await interaction.response.edit_message( + embed=warning("You do not qualify for this giveaway based on your answers."), + view=None + ) + return + + self.current_index += 1 + + # Check if finished + if self.current_index >= len(self.questions): + success_joined = await self.cog.manager.enter(self.row['message_id'], self.user_id) + msg = 'Entry successful! Good luck.' if success_joined else 'You have already entered this giveaway.' + await interaction.response.edit_message(embed=success(msg), view=None) + else: + await self._update_question(interaction) + + @discord.ui.button(label='Yes', style=discord.ButtonStyle.green) + async def yes(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.handle_answer(interaction, 'yes') + + @discord.ui.button(label='No', style=discord.ButtonStyle.red) + async def no(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.handle_answer(interaction, 'no') + + +class JoinView(discord.ui.View): + def __init__(self, cog: Giveaway, disabled: bool = False): + super().__init__(timeout=None) + self.cog = cog + self.join_btn.disabled = disabled + + @discord.ui.button(label="🎉 Join Giveaway", style=discord.ButtonStyle.primary, custom_id="join_giveaway_dynamic") + async def join_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + row = await self.cog.manager.get_active(interaction.message.id) + if not row: + return await interaction.response.send_message(embed=warning("This giveaway has ended."), ephemeral=True) + + questions = self._normalize_questions(row.get("questions")) + + if not questions: + joined = await self.cog.manager.enter(row["message_id"], interaction.user.id) + embed = success("You joined the giveaway!") if joined else warning("You already joined this giveaway.") + return await interaction.response.send_message(embed=embed, ephemeral=True) + + row_dict = dict(row) + row_dict["questions"] = questions + view = Questionnaire(self.cog, row_dict, interaction.user.id) + + await interaction.response.send_message( + embed=info(questions[0]["question"], self.cog.bot.user, f"Question 1/{len(questions)}"), + view=view, + ephemeral=True + ) + + def _normalize_questions(self, raw) -> list: + if not raw: return [] + try: + data = json.loads(raw) if isinstance(raw, str) else raw + if not isinstance(data, list): return [] + + normalized = [] + for item in data: + if isinstance(item, dict) and "question" in item: + normalized.append({"question": item["question"], "answer": item.get("answer", "yes").lower()}) + return normalized + except (json.JSONDecodeError, TypeError): + return [] + + +class Giveaway(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.manager = bot.giveaway_manager + self.tasks: Dict[int, asyncio.Task] = {} + self.bot.add_view(JoinView(self)) + + async def cog_load(self): + """Restart background tasks for pending giveaways on reboot.""" + pending = await self.manager.get_pending() + for row in pending: + self.tasks[row['message_id']] = asyncio.create_task( + self.finish_task(row['message_id'], row['ends_at']) + ) + + @app_commands.command(name='giveaway_create', description='Start a new giveaway') + @app_commands.describe(duration_minutes="How long the giveaway lasts", winners="Number of winners to pick") + @app_commands.check(check_if_tortoise_staff) + async def giveaway_create( + self, + interaction: discord.Interaction, + duration_minutes: app_commands.Range[int, 1, 10080], + winners: app_commands.Range[int, 1, 10] = 1 + ): + await interaction.response.send_modal(CreateModal(self, duration_minutes, winners)) + + async def finish_task(self, message_id: int, end_time: datetime): + # Wait until expiry + delay = (end_time - datetime.now(timezone.utc)).total_seconds() + if delay > 0: + await asyncio.sleep(delay) + + row = await self.manager.get_active(message_id) + if not row: + return + + entries = await self.manager.get_entries(message_id) + winner_count = min(len(entries), row['winners']) + picks = random.sample(entries, winner_count) if entries else [] + + await self.manager.finish(message_id, picks) + + # Update the original message + guild = self.bot.get_guild(row['guild_id']) + if not guild: return + + channel = guild.get_channel(row['channel_id']) + if not channel: return + + try: + msg = await channel.fetch_message(message_id) + winner_mentions = ', '.join(f'<@{u}>' for u in picks) if picks else 'No valid entries.' + + result_text = ( + f"**{row['name']}**\n" + f"{row['description']}\n\n" + f"**Prizes**\n{row['prizes']}\n\n" + f"**Winners**\n{winner_mentions}" + ) + + end_embed = info(result_text, self.bot.user, '🎉 Giveaway Ended') + await msg.edit(embed=end_embed, view=JoinView(self, disabled=True)) + except discord.NotFound: + pass + except Exception as e: + print(f"Error finishing giveaway {message_id}: {e}") + + +async def setup(bot): + await bot.add_cog(Giveaway(bot)) diff --git a/bot/manager.py b/bot/manager.py index 6d77917..dc68f6b 100644 --- a/bot/manager.py +++ b/bot/manager.py @@ -651,3 +651,115 @@ async def get_team_members(self, team_id: int): WHERE team_id=$1 """, team_id) + +class GiveawayManager: + def __init__(self, db): + self.db = db + + async def setup(self): + await self.db.pool.execute(""" + CREATE TABLE IF NOT EXISTS giveaways ( + message_id BIGINT PRIMARY KEY, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + host_id BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT, + prizes TEXT NOT NULL, + questions JSONB NOT NULL DEFAULT '[]'::jsonb, + winners INTEGER NOT NULL DEFAULT 10, + ends_at TIMESTAMPTZ NOT NULL, + ended BOOLEAN NOT NULL DEFAULT FALSE, + winner_ids BIGINT[] NOT NULL DEFAULT '{}' + ) + """) + + await self.db.pool.execute(""" + CREATE TABLE IF NOT EXISTS giveaway_entries ( + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + PRIMARY KEY (message_id, user_id) + ) + """) + + async def create_giveaway( + self, + message_id: int, + guild_id: int, + channel_id: int, + host_id: int, + name: str, + description: str, + prizes: str, + questions_json: str, + winners: int, + ends_at, + ): + await self.db.pool.execute(""" + INSERT INTO giveaways ( + message_id, guild_id, channel_id, host_id, + name, description, prizes, questions, + winners, ends_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10 + ) + """, + message_id, guild_id, channel_id, host_id, + name, description, prizes, questions_json, + winners, ends_at) + + async def get_active(self, message_id: int): + return await self.db.pool.fetchrow( + "SELECT * FROM giveaways WHERE message_id=$1 AND ended=FALSE", + message_id, + ) + + async def get_pending(self): + return await self.db.pool.fetch( + "SELECT * FROM giveaways WHERE ended=FALSE ORDER BY ends_at ASC" + ) + + async def enter(self, message_id: int, user_id: int) -> bool: + result = await self.db.pool.execute(""" + INSERT INTO giveaway_entries (message_id, user_id) + VALUES ($1,$2) + ON CONFLICT DO NOTHING + """, message_id, user_id) + return result != "INSERT 0 0" + + async def get_entries(self, message_id: int): + rows = await self.db.pool.fetch( + "SELECT user_id FROM giveaway_entries WHERE message_id=$1", + message_id, + ) + return [r['user_id'] for r in rows] + + async def get_entry_count(self, message_id: int) -> int: + return await self.db.pool.fetchval( + "SELECT COUNT(*) FROM giveaway_entries WHERE message_id=$1", + message_id, + ) or 0 + + async def finish(self, message_id: int, winner_ids: list[int]): + await self.db.pool.execute(""" + UPDATE giveaways + SET ended=TRUE, winner_ids=$2 + WHERE message_id=$1 + """, message_id, winner_ids) + + async def get_giveaway(self, message_id: int): + return await self.db.pool.fetchrow( + "SELECT * FROM giveaways WHERE message_id=$1", + message_id, + ) + + async def delete_giveaway(self, message_id: int): + await self.db.pool.execute( + "DELETE FROM giveaway_entries WHERE message_id=$1", + message_id, + ) + await self.db.pool.execute( + "DELETE FROM giveaways WHERE message_id=$1", + message_id, + ) + From 681c50274a510d954379522acf7ed716461854b1 Mon Sep 17 00:00:00 2001 From: Ryuga Date: Wed, 22 Apr 2026 15:52:06 +0530 Subject: [PATCH 3/3] Remove points notification --- bot/cogs/leaderboard.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/cogs/leaderboard.py b/bot/cogs/leaderboard.py index 33b8696..4abe21e 100644 --- a/bot/cogs/leaderboard.py +++ b/bot/cogs/leaderboard.py @@ -63,6 +63,7 @@ async def rmpoints( interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1, 10_000], + silent: bool = True, ): await interaction.response.defer(ephemeral=True) @@ -80,6 +81,19 @@ async def rmpoints( f"Removed by: {interaction.user.display_name}", ) + if not silent: + dm_embed = info(( + f"**{amount}** points removed\n" + f"New total: **{new_total}** points." + ), + self.bot.user, + "Points Removed ;(", + ) + try: + await member.send(embed=dm_embed) + except discord.Forbidden: + pass + await self.log_channel.send(embed=embed) await interaction.followup.send(