diff --git a/.github/workflows/pr-issue-reference-check.yml b/.github/workflows/pr-issue-reference-check.yml new file mode 100644 index 0000000..d477a78 --- /dev/null +++ b/.github/workflows/pr-issue-reference-check.yml @@ -0,0 +1,43 @@ +name: PR Issue Reference Check + +on: + pull_request: + branches: [dev] + types: [opened, edited, synchronize, reopened] + +jobs: + pr-issue-reference-check: + runs-on: ubuntu-latest + steps: + - name: Check issue number is consistent across branch, title, and body + env: + BRANCH: ${{ github.head_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # Extract leading issue number from branch name + if [[ "$BRANCH" =~ ^([0-9]+)- ]]; then + ISSUE_NUM="${BASH_REMATCH[1]}" + echo "Issue number: $ISSUE_NUM" + else + echo "ERROR: Branch name '$BRANCH' does not start with an issue number (e.g. 258-Bug)." + exit 1 + fi + + # Assert PR body contains "Closes #" (case-insensitive) + if echo "$PR_BODY" | grep -iqE "closes #${ISSUE_NUM}([^0-9]|$)"; then + echo "PR body contains 'Closes #${ISSUE_NUM}'. OK." + else + echo "ERROR: PR body must contain 'Closes #${ISSUE_NUM}'." + exit 1 + fi + + # Assert PR title references the issue number as a whole word + if echo "$PR_TITLE" | grep -qwE "${ISSUE_NUM}"; then + echo "PR title references issue #${ISSUE_NUM}. OK." + else + echo "ERROR: PR title must reference issue number ${ISSUE_NUM}." + exit 1 + fi + + echo "All issue reference checks passed." diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 0000000..9368755 --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,26 @@ +name: Version Check + +on: + pull_request: + branches: [main] + +jobs: + version-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch main branch + run: git fetch origin main + + - name: Check version bump + run: | + PR_VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)"/\1/') + MAIN_VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version' | sed 's/.*"\(.*\)"/\1/') + echo "PR version: $PR_VERSION" + echo "Main version: $MAIN_VERSION" + if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then + echo "::error::Version in pyproject.toml ($PR_VERSION) has not been bumped. Please update the version before merging to main." + exit 1 + fi + echo "Version bumped: $MAIN_VERSION -> $PR_VERSION" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79e7b08 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shiven Ajwaliya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/database/mongo.py b/database/mongo.py index 99c4f22..4b152a1 100644 --- a/database/mongo.py +++ b/database/mongo.py @@ -229,7 +229,7 @@ async def add_hacked_user(user_id: str, reason: str = "Compromised Account"): async def get_hacked_users(): """Retrieves all currently hacked users.""" cursor = db.hacked_users.find({"status": "hacked"}) - return await cursor.to_list(length=100) + return await cursor.to_list(length=None) async def remove_hacked_user(user_id: str): diff --git a/features/config.py b/features/config.py index 52a62e5..c947357 100644 --- a/features/config.py +++ b/features/config.py @@ -36,6 +36,7 @@ SUPPORT_STAFF_APPS_CATEGORY_ID = 1330243733177897030 SUPPORT_PARTNERSHIP_CATEGORY_ID = 1330243884911169657 REDEMPTION_TICKET_CATEGORY_ID = 1481456156080738346 + BOTS_CATEGORY_ID = 767612682357964810 SUPPORT_TRANSCRIPT_LOG_CHANNEL_ID = 1481117793222004746 SUPPORT_STAFF_APPS_INFO_CHANNEL_ID = 1261020578790248459 @@ -212,6 +213,7 @@ SUPPORT_STAFF_APPS_CATEGORY_ID = 1480728684704043098 SUPPORT_PARTNERSHIP_CATEGORY_ID = 1480728776919879721 REDEMPTION_TICKET_CATEGORY_ID = 1481455542236086293 + BOTS_CATEGORY_ID = 1506812222704451677 SUPPORT_TRANSCRIPT_LOG_CHANNEL_ID = 1480735066924781711 SUPPORT_STAFF_APPS_INFO_CHANNEL_ID = 1481083131342618754 diff --git a/features/economy.py b/features/economy.py index 2eb8518..ce22784 100644 --- a/features/economy.py +++ b/features/economy.py @@ -27,6 +27,7 @@ # --- CONFIGURATION --- from features.config import ( ADMIN_ROLE_ID, + BOTS_CATEGORY_ID, GENERAL_CHANNEL_ID, EVENT_ANNOUNCEMENTS_CHANNEL_ID, SHOP_DATA, @@ -640,6 +641,10 @@ async def on_message(self, message: discord.Message): if message.content.startswith("!"): return + # Skip token rewards for messages in the 'BOTS' category + if message.channel.category and message.channel.category.id == BOTS_CATEGORY_ID: + return + user_id = str(message.author.id) current_timestamp = time.time() datetime.utcnow().strftime("%Y-%m-%d") @@ -682,12 +687,12 @@ async def on_message(self, message: discord.Message): if should_award_tokens: earned_tokens = random.randint(2, 5) - # Booster Bonus: 7% Chance (Avg 2% increase) + # Booster Bonus: 17.5% Chance (Avg 5% increase) SERVER_BOOSTER_ROLE_ID = 647685778255642626 if message.guild: booster_role = message.guild.get_role(SERVER_BOOSTER_ROLE_ID) if booster_role and booster_role in message.author.roles: - if random.random() < 0.07: + if random.random() < 0.175: earned_tokens += 1 current_balance = await get_user_balance(user_id) @@ -883,12 +888,14 @@ async def redeem(self, interaction: discord.Interaction, item: str): await interaction.response.send_message(embed=embed, ephemeral=True) return + await interaction.response.defer() + try: if ( not isinstance(REDEMPTION_TICKET_CATEGORY_ID, int) or REDEMPTION_TICKET_CATEGORY_ID <= 0 ): - await interaction.response.send_message( + await interaction.followup.send( "❌ Redemption category is not configured.", ephemeral=True, ) @@ -896,12 +903,14 @@ async def redeem(self, interaction: discord.Interaction, item: str): category = interaction.guild.get_channel(REDEMPTION_TICKET_CATEGORY_ID) if not isinstance(category, discord.CategoryChannel): - await interaction.response.send_message( + await interaction.followup.send( "❌ Configured redemption category channel was not found.", ephemeral=True, ) return + balance_before = await get_user_balance(user_id) + await remove_item_token(user_id, item) tracking_keys = { @@ -964,17 +973,31 @@ async def redeem(self, interaction: discord.Interaction, item: str): description=f"A ticket has been created in {ch.mention}.\nPlease provide the following details to redeem your **{item_info['display']}**:\n{instructions}", color=discord.Color.green(), ) - await interaction.response.send_message(embed=embed) + await interaction.followup.send(embed=embed) + item_price = item_info["price"] + balance_after = balance_before + balance_display_before = balance_before + item_price ticket_embed = discord.Embed( title=f"🎫 **{item.title()} Redemption Ticket**", description=f"{interaction.user.mention}, please provide the following details in this ticket channel:\n\n{instructions}", color=discord.Color.blue(), ) + ticket_embed.add_field( + name="Item Price", value=f"{item_price:,} R7 tokens", inline=True + ) + ticket_embed.add_field( + name="Balance Before", + value=f"{balance_display_before:,} R7 tokens", + inline=True, + ) + ticket_embed.add_field( + name="Balance After", value=f"{balance_after:,} R7 tokens", inline=True + ) await ch.send(embed=ticket_embed) except Exception as e: await add_item_token(user_id, item, quantity=1) - await interaction.response.send_message( + await interaction.followup.send( f"❌ **Error** Failed to create ticket: {e}", ephemeral=True ) @@ -1324,7 +1347,7 @@ async def economy_help(self, interaction: discord.Interaction): "*Requires 5 messages sent since your last `/daily` claim.*\n" f"🪂 **Supply Drops:** Random crates appear in {general_ch}! Click the button to claim.\n" f"🏆 **Events:** Earn massive token rewards in {event_ch}.\n" - "🚀 **Booster Bonus:** Server Boosters receive a **2% increase** in coins on average." + "🚀 **Booster Bonus:** Server Boosters receive a **5% increase** in coins on average." ) earn_embed.add_field(name="📈 Earning Methods", value=earn_text, inline=False) earn_embed.set_thumbnail(url=self.bot.user.display_avatar.url) diff --git a/features/security.py b/features/security.py index bdfff11..f74c215 100644 --- a/features/security.py +++ b/features/security.py @@ -2,6 +2,7 @@ from discord import app_commands from discord.ext import commands from datetime import timedelta, datetime +from math import ceil import asyncio from database.mongo import add_hacked_user, get_hacked_users, remove_hacked_user @@ -26,9 +27,7 @@ async def has_security_permission(self, source): return False # --- CORE LOGIC: The shared hacked/purge process --- - async def _execute_hacked_action( - self, guild, target_user, moderator, days_to_clean=7 - ): + async def _execute_hacked_action(self, guild, target_user, moderator): """ Shared logic that performs the timeout, DB update, and message purge. """ @@ -68,7 +67,7 @@ async def _execute_hacked_action( print(f"Failed to DM hacked user {target_user.id}: {e}") # 5. Global Message Purge - cutoff_date = datetime.utcnow() - timedelta(days=days_to_clean) + cutoff_date = datetime.utcnow() - timedelta(hours=12) total_deleted = 0 channels_checked = 0 @@ -107,7 +106,7 @@ async def _execute_hacked_action( embed.add_field(name="Status", value=timeout_status, inline=False) embed.add_field( name="Cleanup Stats", - value=f"🗑️ Deleted **{total_deleted} messages** across **{channels_checked} channels** (Past {days_to_clean} days).", + value=f"🗑️ Deleted **{total_deleted} messages** across **{channels_checked} channels** (Past 12 hours).", inline=False, ) embed.add_field( @@ -130,14 +129,11 @@ async def _send_security_logs(self, embed): name="hacked", description="MOD/ADMIN: Flag user as hacked, timeout them, and delete messages.", ) - @app_commands.describe( - user="The hacked user", days_to_clean="Days of messages to delete (default 7)" - ) + @app_commands.describe(user="The hacked user") async def hacked_slash( self, interaction: discord.Interaction, user: discord.Member, - days_to_clean: int = 7, ): if not await self.has_security_permission(interaction): await interaction.response.send_message( @@ -147,7 +143,7 @@ async def hacked_slash( await interaction.response.defer() result_embed = await self._execute_hacked_action( - interaction.guild, user, interaction.user, days_to_clean + interaction.guild, user, interaction.user ) await interaction.followup.send(embed=result_embed) @@ -163,6 +159,9 @@ async def hacked_text(self, ctx): if not await self.has_security_permission(ctx): return + if ctx.message.content.strip() != "!hacked": + return + if not ctx.message.reference: await ctx.send("❌ Reply to a message with `!hacked` to flag that user.") return @@ -229,24 +228,71 @@ async def hackedlist(self, interaction: discord.Interaction): ) return - embed = discord.Embed( - title="🚨 Hacked Users List", color=discord.Color.dark_red() + view = HackedListView(users, interaction.user) + await interaction.followup.send(embed=view.create_embed(), view=view) + + +class HackedListView(discord.ui.View): + def __init__(self, users: list, author: discord.User): + super().__init__(timeout=300) + self.users = sorted( + users, key=lambda u: u.get("timestamp", datetime.min), reverse=True ) - description_lines = [] - for u in users: + self.author = author + self.per_page = 10 + self.current_page = 0 + self.total_pages = ceil(len(users) / self.per_page) + self.update_buttons() + + def create_embed(self) -> discord.Embed: + start = self.current_page * self.per_page + end = start + self.per_page + page_users = self.users[start:end] + + entries = [] + for u in page_users: user_id = u["_id"] reason = u.get("reason", "No reason provided") time_str = u.get("timestamp", datetime.utcnow()).strftime("%Y-%m-%d") - description_lines.append( - f"• <@{user_id}> (`{user_id}`)\n Reason: *{reason}* ({time_str})" + entries.append( + f"<@{user_id}> (`{user_id}`)\nReason: *{reason}* ({time_str})" ) - full_text = "\n".join(description_lines) - if len(full_text) > 4000: - full_text = full_text[:3900] + "..." + embed = discord.Embed( + title="🚨 Hacked Users List", + description="\n\n".join(entries), + color=discord.Color.dark_red(), + ) + embed.set_footer(text=f"Page {self.current_page + 1}/{self.total_pages}") + return embed + + def update_buttons(self): + self.prev_button.disabled = self.current_page == 0 + self.next_button.disabled = self.current_page == self.total_pages - 1 - embed.description = full_text - await interaction.followup.send(embed=embed) + @discord.ui.button( + label="◀ Previous", style=discord.ButtonStyle.blurple, disabled=True + ) + async def prev_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.author.id: + await interaction.response.defer() + return + self.current_page -= 1 + self.update_buttons() + await interaction.response.edit_message(embed=self.create_embed(), view=self) + + @discord.ui.button(label="Next ▶", style=discord.ButtonStyle.blurple) + async def next_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.author.id: + await interaction.response.defer() + return + self.current_page += 1 + self.update_buttons() + await interaction.response.edit_message(embed=self.create_embed(), view=self) async def setup(bot): diff --git a/features/tourney/tourney_commands.py b/features/tourney/tourney_commands.py index 7631395..ebfd4f6 100644 --- a/features/tourney/tourney_commands.py +++ b/features/tourney/tourney_commands.py @@ -1161,6 +1161,7 @@ async def blacklist_list(self, interaction: discord.Interaction): def setup_tourney_commands(bot: commands.Bot): sticky_redirect_state = {"enabled": False, "region": None} + admin_role_original_name: list[str | None] = [None] # mutable container for closure @bot.command(name="close", aliases=["c"]) async def close_command(ctx: commands.Context): @@ -1486,6 +1487,22 @@ async def start_tourney_command(ctx: commands.Context, region: str = None): except Exception as e: print(f"Failed to grant timeout permission to Tourney Admin role: {e}") + # Rename Admin role to indicate it is not a Tourney Admin. + admin_role = guild.get_role(ADMIN_ROLE_ID) + if admin_role: + admin_role_original_name[0] = admin_role.name + + async def _rename_admin_role(): + try: + await admin_role.edit(name="[NOT TOURNEY ADMIN] Admin") + await ctx.send( + f"✏️ **{admin_role_original_name[0]}** has been renamed to **[NOT TOURNEY ADMIN] Admin**." + ) + except Exception as e: + print(f"Failed to rename Admin role: {e}") + + asyncio.create_task(_rename_admin_role()) + # START THE DASHBOARD dashboard_cog = bot.get_cog("QueueDashboard") if dashboard_cog: @@ -1508,6 +1525,17 @@ async def start_tourney_command(ctx: commands.Context, region: str = None): } await dashboard_cog.start_dashboard() + # Apply 60s slow mode to general channel during tourney. + general_channel = guild.get_channel(GENERAL_CHANNEL_ID) + if isinstance(general_channel, discord.TextChannel): + try: + await general_channel.edit(slowmode_delay=60) + await ctx.send( + f"🐢 Slow mode (60s) has been enabled in {general_channel.mention}." + ) + except Exception as e: + print(f"Failed to set slow mode on general channel: {e}") + @bot.command(name="endtourney") async def end_tourney_command(ctx: commands.Context): """ @@ -1691,6 +1719,29 @@ async def _retry_winner_post(): await unlock_command(ctx) + # Restore Admin role name. + admin_role = guild.get_role(ADMIN_ROLE_ID) + if admin_role: + original_name = admin_role_original_name[0] or "Admin" + try: + await admin_role.edit(name=original_name) + await ctx.send( + f"✏️ Admin role has been restored to **{original_name}**." + ) + except Exception as e: + print(f"Failed to restore Admin role name: {e}") + + # Remove slow mode from general channel now that tourney is over. + general_channel = guild.get_channel(GENERAL_CHANNEL_ID) + if isinstance(general_channel, discord.TextChannel): + try: + await general_channel.edit(slowmode_delay=0) + await ctx.send( + f"🐇 Slow mode has been removed from {general_channel.mention}." + ) + except Exception as e: + print(f"Failed to remove slow mode on general channel: {e}") + from features.config import SPANISH_CHANNEL_ID guild = ctx.guild diff --git a/pyproject.toml b/pyproject.toml index 88638e4..e3dbc45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remaining7-discord-bot" -version = "1.9.0" +version = "1.9.1" [tool.pytest.ini_options] asyncio_mode = "auto"