diff --git a/birthday/birthday.py b/birthday/birthday.py index b669947..ba06963 100644 --- a/birthday/birthday.py +++ b/birthday/birthday.py @@ -158,10 +158,13 @@ class Birthday(commands.Cog): return filtered[:25] # Discord limit of 25 at a time - @app_commands.command(name="setbirthday", description="Set your birthday timezone.") + # Create command groups + birthday_group = app_commands.Group(name="birthday", description="Manage birthday settings") + + @birthday_group.command(name="set", description="Set your birthday and timezone") @app_commands.describe(date="Your birthday (MM-DD)", timezone="Your timezone, search your city/country to find it!") @app_commands.autocomplete(timezone=timezone_autocomplete) - async def setbirthday(self, interaction: discord.Interaction, date: str, timezone: str): + async def set_birthday(self, interaction: discord.Interaction, date: str, timezone: str): try: datetime.strptime(date, "%m-%d") except ValueError: @@ -180,8 +183,8 @@ class Birthday(commands.Cog): await interaction.response.send_message(f"šŸŽ‚ Birthday set to '{date}' in timezone '{timezone}'!", ephemeral=True) - @app_commands.command(name="removebirthday", description="Remove your saved birthday.") - async def removebirthday(self, interaction: discord.Interaction): + @birthday_group.command(name="remove", description="Remove your saved birthday") + async def remove_birthday(self, interaction: discord.Interaction): conn = sqlite3.connect(self.db_path) c = conn.cursor() c.execute("DELETE FROM birthdays WHERE user_id = ?", (interaction.user.id,)) @@ -194,29 +197,9 @@ class Birthday(commands.Cog): else: await interaction.response.send_message("You don't have a birthday set.", ephemeral=True) - @app_commands.command(name="setbirthdaychannel", description="Set the channel for birthday announcements.") - @app_commands.describe(channel="Channel where birthday announcements will be sent.") - async def setbirthdaychannel(self, interaction: discord.Interaction, channel: discord.TextChannel): - member = interaction.user - if not ModerationBase.is_admin(): - await interaction.response.send_message("You do not have permission to use this command.", ephemeral=True) - return - - conn = sqlite3.connect(self.db_path) - c = conn.cursor() - c.execute(""" - INSERT INTO guild_settings (guild_id, channel_id) - VALUES (?, ?) - ON CONFLICT(guild_id) DO UPDATE SET channel_id=excluded.channel_id - """, (interaction.guild.id, channel.id)) - conn.commit() - conn.close() - - await interaction.response.send_message(f"Birthday announcements will be sent in {channel.mention}") - - @app_commands.command(name="listbirthdays", description="List all birthdays for a specific month.") + @birthday_group.command(name="list", description="List all birthdays for a specific month") @app_commands.describe(month="Specify a month (1-12) to see birthdays for that month") - async def listbirthdays(self, interaction: discord.Interaction, month: int): + async def list_birthdays(self, interaction: discord.Interaction, month: int): if month < 1 or month > 12: await interaction.response.send_message("Invalid month! Please use a number between 1 and 12.", ephemeral=True) return @@ -261,6 +244,24 @@ class Birthday(commands.Cog): ) await interaction.response.send_message(embed=embed) + @birthday_group.command(name="channel", description="Set the channel for birthday announcements") + @app_commands.describe(channel="Channel where birthday announcements will be sent") + async def set_channel(self, interaction: discord.Interaction, channel: discord.TextChannel): + if not ModerationBase.is_admin(): + await interaction.response.send_message("You do not have permission to use this command.", ephemeral=True) + return + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + INSERT INTO guild_settings (guild_id, channel_id) + VALUES (?, ?) + ON CONFLICT(guild_id) DO UPDATE SET channel_id=excluded.channel_id + """, (interaction.guild.id, channel.id)) + conn.commit() + conn.close() + + await interaction.response.send_message(f"Birthday announcements will be sent in {channel.mention}") @tasks.loop(minutes=1) async def check_birthdays(self): diff --git a/embed/embedcolor.py b/embed/embedcolor.py index 6eacd52..df19031 100644 --- a/embed/embedcolor.py +++ b/embed/embedcolor.py @@ -9,8 +9,7 @@ class EmbedColor(commands.Cog): def __init__(self, bot): self.bot = bot self.setup_table() - - + def get_db(self): return sqlite3.connect(DB_PATH) @@ -25,8 +24,8 @@ class EmbedColor(commands.Cog): """) db.commit() db.close() - - def get_user_color(self, user:discord.User) -> discord.Color: + + def get_user_color(self, user: discord.User) -> discord.Color: db = self.get_db() cursor = db.cursor() cursor.execute("SELECT color FROM user_embed_colors WHERE user_id = ?", (user.id,)) @@ -35,12 +34,17 @@ class EmbedColor(commands.Cog): if result and result[0]: return discord.Color(int(result[0], 16)) return user.accent_color or discord.Color.blurple() - - @app_commands.command(name="setembedcolor", description="Set your preferred embed color (hex, e.g. #ff66cc).") - async def setcolor(self, interaction: discord.Interaction, hex_color: str): + + # Create a command group + embed_group = app_commands.Group(name="embedcolor", description="Manage your embed color preferences") + + @embed_group.command(name="set", description="Set your preferred embed color (hex, e.g. #ff66cc)") + @app_commands.describe(hex_color="Hex color code (e.g., #ff66cc)") + async def set_color(self, interaction: discord.Interaction, hex_color: str): if not hex_color.startswith("#") or len(hex_color) != 7: await interaction.response.send_message("Please provide a valid hex color in the format: `#rrggbb`.", ephemeral=True) return + try: int(hex_color[1:], 16) except ValueError: @@ -56,11 +60,11 @@ class EmbedColor(commands.Cog): """, (interaction.user.id, hex_color[1:])) db.commit() db.close() - + await interaction.response.send_message(f"Your embed color has been set to `{hex_color}`") - - @app_commands.command(name="myembedcolor", description="View your current embed color.") - async def mycolor(self, interaction: discord.Interaction): + + @embed_group.command(name="view", description="View your current embed color") + async def view_color(self, interaction: discord.Interaction): color = self.get_user_color(interaction.user) hex_code = f"#{color.value:06x}" embed = discord.Embed( @@ -69,6 +73,20 @@ class EmbedColor(commands.Cog): color=color ) await interaction.response.send_message(embed=embed) + + @embed_group.command(name="remove", description="Remove your custom embed color") + async def remove_color(self, interaction: discord.Interaction): + db = self.get_db() + cursor = db.cursor() + cursor.execute("DELETE FROM user_embed_colors WHERE user_id = ?", (interaction.user.id,)) + changes = db.total_changes + db.commit() + db.close() + + if changes > 0: + await interaction.response.send_message("Your custom embed color has been removed. Default colors will be used.", ephemeral=True) + else: + await interaction.response.send_message("You don't have a custom embed color set.", ephemeral=True) async def setup(bot): await bot.add_cog(EmbedColor(bot)) \ No newline at end of file diff --git a/events/git_webhook.py b/events/git_webhook.py new file mode 100644 index 0000000..a742cb5 --- /dev/null +++ b/events/git_webhook.py @@ -0,0 +1,191 @@ +""" +Discord bot cog that runs a webhook server to receive Git commits. +The bot receives the webhook from GitHub and posts directly to Discord. + +Add to your .env (optional): +GITHUB_WEBHOOK_SECRET=your_secret_here +WEBHOOK_PORT=5000 +""" + +import discord +from discord.ext import commands +from aiohttp import web +import hmac +import hashlib +import os +from dotenv import load_dotenv + +load_dotenv() + +COMMIT_CHANNEL_ID = 876777562599194644 +USER_ID_TO_PING = 252130669919076352 +WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") +WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 5000)) + +class GitWebhook(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.app = web.Application() + self.app.router.add_post('/webhook', self.handle_webhook) + self.app.router.add_get('/health', self.health_check) + self.runner = None + self.site = None + + async def cog_load(self): + """Start the webhook server when cog loads.""" + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, '0.0.0.0', WEBHOOK_PORT) + await self.site.start() + print(f"āœ… Git webhook server started on port {WEBHOOK_PORT}") + print(f" Configure GitHub to send webhooks to: http://185.187.170.61:{WEBHOOK_PORT}/webhook") + + async def cog_unload(self): + """Stop the webhook server when cog unloads.""" + if self.site: + await self.site.stop() + if self.runner: + await self.runner.cleanup() + print("šŸ›‘ Git webhook server stopped") + + def verify_signature(self, payload_body, signature_header): + """Verify GitHub webhook signature for security.""" + if not WEBHOOK_SECRET: + return True + + hash_object = hmac.new( + WEBHOOK_SECRET.encode('utf-8'), + msg=payload_body, + digestmod=hashlib.sha256 + ) + expected_signature = "sha256=" + hash_object.hexdigest() + return hmac.compare_digest(expected_signature, signature_header) + + async def health_check(self, request): + """Health check endpoint.""" + return web.json_response({"status": "healthy"}) + + async def handle_webhook(self, request): + """Handle incoming Git webhook from GitHub/GitLab.""" + try: + # Verify signature if secret is configured + if WEBHOOK_SECRET: + signature = request.headers.get('X-Hub-Signature-256', '') + body = await request.read() + if not self.verify_signature(body, signature): + return web.json_response({"error": "Invalid signature"}, status=403) + data = await request.json() + else: + data = await request.json() + + # Handle GitHub ping event (test from GitHub) + if 'zen' in data and 'hook_id' in data: + print("āœ… Received GitHub ping event - webhook is configured correctly!") + return web.json_response({"status": "pong"}, status=200) + + # Get the Discord channel + channel = self.bot.get_channel(COMMIT_CHANNEL_ID) + if not channel: + print(f"āŒ Channel {COMMIT_CHANNEL_ID} not found!") + return web.json_response({"error": "Channel not found"}, status=500) + + # Handle GitHub push events + if 'commits' in data and 'repository' in data: + await self.handle_github_push(data, channel) + return web.json_response({"status": "success"}, status=200) + + # Handle GitLab push events + elif 'project' in data and 'commits' in data: + await self.handle_gitlab_push(data, channel) + return web.json_response({"status": "success"}, status=200) + + print(f"āš ļø Unknown webhook format. Keys in data: {list(data.keys())}") + return web.json_response({"error": "Unknown webhook format"}, status=400) + + except Exception as e: + print(f"āŒ Webhook error: {e}") + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + + async def handle_github_push(self, data, channel): + """Handle GitHub push webhook.""" + repo_name = data['repository']['full_name'] + repo_url = data['repository']['html_url'] + branch = data['ref'].split('/')[-1] + pusher = data['pusher']['name'] + commits = data['commits'] + compare_url = data.get('compare', '') + + if not commits: + return + + # Build commit list + commit_lines = [] + for commit in commits[:10]: # Show up to 10 commits + short_sha = commit['id'][:7] + message = commit['message'].split('\n')[0] # First line only + if len(message) > 72: + message = message[:69] + "..." + author = commit['author']['name'] + url = commit['url'] + commit_lines.append(f"[`{short_sha}`]({url}) {message}") + + # Create embed + embed = discord.Embed( + title=f"šŸ“ [{repo_name}:{branch}] {len(commits)} new commit{'s' if len(commits) != 1 else ''}", + url=compare_url if compare_url else repo_url, + description="\n".join(commit_lines), + color=discord.Color.blue() + ) + + if len(commits) > 10: + embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*" + + embed.set_author(name=pusher, icon_url="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png") + embed.set_footer(text="GitHub") + + # Send to Discord + await channel.send(f"<@{USER_ID_TO_PING}>", embed=embed) + + async def handle_gitlab_push(self, data, channel): + """Handle GitLab push webhook.""" + repo_name = data['project']['path_with_namespace'] + repo_url = data['project']['web_url'] + branch = data['ref'].split('/')[-1] + pusher = data['user_name'] + commits = data['commits'] + + if not commits: + return + + # Build commit list + commit_lines = [] + for commit in commits[:10]: + short_sha = commit['id'][:7] + message = commit['message'].split('\n')[0] + if len(message) > 72: + message = message[:69] + "..." + author = commit['author']['name'] + url = commit['url'] + commit_lines.append(f"[`{short_sha}`]({url}) {message}") + + # Create embed + embed = discord.Embed( + title=f"šŸ“ [{repo_name}:{branch}] {len(commits)} new commit{'s' if len(commits) != 1 else ''}", + url=repo_url, + description="\n".join(commit_lines), + color=0xFC6D26 # GitLab orange + ) + + if len(commits) > 10: + embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*" + + embed.set_author(name=pusher) + embed.set_footer(text="GitLab") + + # Send to Discord + await channel.send(f"<@{USER_ID_TO_PING}>", embed=embed) + +async def setup(bot): + await bot.add_cog(GitWebhook(bot)) \ No newline at end of file diff --git a/xp/calculate.py b/xp/calculate.py index 06eed1e..c0944ba 100644 --- a/xp/calculate.py +++ b/xp/calculate.py @@ -26,94 +26,106 @@ class CalculateCommand(commands.Cog): user: discord.Member = None, board_type: app_commands.Choice[str] = None ): - if user is None: - user = interaction.user + # Immediately defer to prevent timeout + await interaction.response.defer(thinking=True, ephemeral=False) - # Load config fresh - config = load_config() - COOLDOWN = config["COOLDOWN"] - random_xp_config = config.get("RANDOM_XP", {"min": 50, "max": 100}) + try: + if user is None: + user = interaction.user - # Determine which database to use - use_lifetime = True if (board_type is None or board_type.value == "lifetime") else False - board_name = "Lifetime" if use_lifetime else "Annual" + # Load config safely + config = load_config() + COOLDOWN = config["COOLDOWN"] + random_xp_config = config.get("RANDOM_XP", {"min": 50, "max": 100}) - conn, cur = get_db(lifetime=use_lifetime) - cur.execute("SELECT xp, level, last_message FROM xp WHERE user_id = ?", (str(user.id),)) - row = cur.fetchone() - conn.close() + # Determine board type + use_lifetime = True if (board_type is None or board_type.value == "lifetime") else False + board_name = "Lifetime" if use_lifetime else "Annual" - if not row: - await interaction.response.send_message( - f"{user.mention} has no XP yet on the **{board_name}** board.", - ephemeral=True - ) - return - - current_xp, current_level, last_message = row + # Database query + conn, cur = get_db(db_type="lifetime") + cur.execute("SELECT xp, level, last_message FROM xp WHERE user_id = ?", (str(user.id),)) + row = cur.fetchone() + conn.close() - if level <= current_level: - await interaction.response.send_message( - f"{user.mention} is already level {current_level} on the **{board_name}** board. Please choose a higher target level.", - ephemeral=True - ) - return - - target_xp = xp_for_level(level) - remaining_xp = target_xp - current_xp + if not row: + await interaction.followup.send( + f"{user.mention} has no XP yet on the **{board_name}** board.", + ephemeral=True + ) + return + + current_xp, current_level, last_message = row - multiplier = get_multiplier(user, apply_multiplier=True) + if level <= current_level: + await interaction.followup.send( + f"{user.mention} is already level {current_level} on the **{board_name}** board. Please choose a higher target level.", + ephemeral=True + ) + return + + target_xp = xp_for_level(level) + remaining_xp = target_xp - current_xp - min_xp_per_msg = int(random_xp_config["min"] * multiplier) - max_xp_per_msg = int(random_xp_config["max"] * multiplier) - avg_xp_per_msg = (min_xp_per_msg + max_xp_per_msg) / 2 + multiplier = get_multiplier(user, apply_multiplier=True) - max_messages = int(remaining_xp / min_xp_per_msg) - min_messages = int(remaining_xp / max_xp_per_msg) - avg_messages = int(remaining_xp / avg_xp_per_msg) + min_xp_per_msg = int(random_xp_config["min"] * multiplier) + max_xp_per_msg = int(random_xp_config["max"] * multiplier) + avg_xp_per_msg = (min_xp_per_msg + max_xp_per_msg) / 2 - time_remaining_seconds = avg_messages * COOLDOWN - days = time_remaining_seconds / 86400 + max_messages = int(remaining_xp / min_xp_per_msg) + min_messages = int(remaining_xp / max_xp_per_msg) + avg_messages = int(remaining_xp / avg_xp_per_msg) - progress = (current_xp / target_xp) * 100 + time_remaining_seconds = avg_messages * COOLDOWN + days = time_remaining_seconds / 86400 - bar_length = 30 - filled = int((progress / 100) * bar_length) - bar = "ā–ˆ" * filled + "ā–‘" * (bar_length - filled) + progress = (current_xp / target_xp) * 100 - current_xp_fmt = f"{current_xp:,}" - target_xp_fmt = f"{target_xp:,}" - remaining_xp_fmt = f"{remaining_xp:,}" - min_messages_fmt = f"{min_messages:,}" - max_messages_fmt = f"{max_messages:,}" - avg_messages_fmt = f"{avg_messages:,}" + # Progress bar + bar_length = 30 + filled = int((progress / 100) * bar_length) + bar = "ā–ˆ" * filled + "ā–‘" * (bar_length - filled) - time_since_last = time.time() - last_message - cooldown_ready = can_get_xp(last_message) - cooldown_status = "" - if not cooldown_ready: - cooldown_remaining = COOLDOWN - time_since_last - cooldown_status = f"\nā³ Cooldown: {int(cooldown_remaining)}s remaining" + # Formatting numbers + current_xp_fmt = f"{current_xp:,}" + target_xp_fmt = f"{target_xp:,}" + remaining_xp_fmt = f"{remaining_xp:,}" + min_messages_fmt = f"{min_messages:,}" + max_messages_fmt = f"{max_messages:,}" + avg_messages_fmt = f"{avg_messages:,}" - response = f"""**{board_name} Level {level} Target** - **Current XP:** {current_xp_fmt} (Level {current_level}) - **Target XP:** {target_xp_fmt} - **Remaining XP:** {remaining_xp_fmt} + # Cooldown info + time_since_last = time.time() - last_message + cooldown_ready = can_get_xp(last_message) + cooldown_status = "" + if not cooldown_ready: + cooldown_remaining = COOLDOWN - time_since_last + cooldown_status = f"\nā³ Cooldown: {int(cooldown_remaining)}s remaining" - **XP per message:** {min_xp_per_msg} - {max_xp_per_msg} - **Messages remaining:** {min_messages_fmt} - {max_messages_fmt} (avg. {avg_messages_fmt}) - **Time remaining:** {days:.1f} days{cooldown_status} + response = f"""**{board_name} Level {level} Target** + **Current XP:** {current_xp_fmt} (Level {current_level}) + **Target XP:** {target_xp_fmt} + **Remaining XP:** {remaining_xp_fmt} - {bar} ({progress:.2f}%)""" + **XP per message:** {min_xp_per_msg} - {max_xp_per_msg} + **Messages remaining:** {min_messages_fmt} - {max_messages_fmt} (avg. {avg_messages_fmt}) + **Time remaining:** {days:.1f} days{cooldown_status} - embed = discord.Embed( - description=response, - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) - ) - embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + {bar} ({progress:.2f}%)""" - await interaction.response.send_message(embed=embed) + # Safe color handling + color_cog = self.bot.get_cog("EmbedColor") + color = color_cog.get_user_color(interaction.user) if color_cog else discord.Color.blurple() + + embed = discord.Embed(description=response, color=color) + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + + await interaction.followup.send(embed=embed) + + except Exception as e: + await interaction.followup.send(f"āš ļø **Error:** {e}", ephemeral=False) async def setup(bot): - await bot.add_cog(CalculateCommand(bot)) \ No newline at end of file + await bot.add_cog(CalculateCommand(bot))