diff --git a/birthday/birthday.py b/birthday/birthday.py new file mode 100644 index 0000000..8e0b426 --- /dev/null +++ b/birthday/birthday.py @@ -0,0 +1,314 @@ +import discord +from discord.ext import commands, tasks +from discord import app_commands +import sqlite3 +from datetime import datetime, timezone +import asyncio +import pytz +import os +from dotenv import load_dotenv + +load_dotenv() +ADMIN_ROLE_ID = int(os.getenv("ADMIN_ROLE_ID"), 0) + + +class Birthday(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.db_path = os.path.join(os.path.dirname(__file__), "birthdays.db") + self._init_db() + self.check_birthdays.start() + + def cog_unload(self): + self.check_birthdays.cancel() + + def _init_db(self): + """Initialize SQLite database.""" + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS birthdays ( + user_id INTEGER PRIMARY KEY, + birthday TEXT NOT NULL, + timezone TEXT NOT NULL + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id INTEGER PRIMARY KEY, + channel_id INTEGER + ) + """) + conn.commit() + conn.close() + + async def timezone_autocomplete(self, interaction: discord.Interaction, current: str): + all_timezones = [ + ("UTC", "UTC"), + # North America + ("US Eastern (America/New_York)", "America/New_York"), + ("US Central (America/Chicago)", "America/Chicago"), + ("US Mountain (America/Denver)", "America/Denver"), + ("US Pacific (America/Los_Angeles)", "America/Los_Angeles"), + ("Alaska (America/Anchorage)", "America/Anchorage"), + ("Hawaii (Pacific/Honolulu)", "Pacific/Honolulu"), + ("Canada Atlantic (America/Halifax)", "America/Halifax"), + ("Canada Central (America/Winnipeg)", "America/Winnipeg"), + ("Canada Mountain (America/Edmonton)", "America/Edmonton"), + ("Canada Pacific (America/Vancouver)", "America/Vancouver"), + ("Canada Eastern (America/Toronto)", "America/Toronto"), + ("Newfoundland (America/St_Johns)", "America/St_Johns"), + ("Mexico City (America/Mexico_City)", "America/Mexico_City"), + # South America + ("Brazil (America/Sao_Paulo)", "America/Sao_Paulo"), + ("Argentina (America/Argentina/Buenos_Aires)", "America/Argentina/Buenos_Aires"), + ("Chile (America/Santiago)", "America/Santiago"), + ("Colombia (America/Bogota)", "America/Bogota"), + ("Peru (America/Lima)", "America/Lima"), + ("Venezuela (America/Caracas)", "America/Caracas"), + # Europe + ("UK (Europe/London)", "Europe/London"), + ("Ireland (Europe/Dublin)", "Europe/Dublin"), + ("Portugal (Europe/Lisbon)", "Europe/Lisbon"), + ("Spain (Europe/Madrid)", "Europe/Madrid"), + ("France (Europe/Paris)", "Europe/Paris"), + ("Netherlands (Europe/Amsterdam)", "Europe/Amsterdam"), + ("Belgium (Europe/Brussels)", "Europe/Brussels"), + ("Germany (Europe/Berlin)", "Europe/Berlin"), + ("Switzerland (Europe/Zurich)", "Europe/Zurich"), + ("Italy (Europe/Rome)", "Europe/Rome"), + ("Austria (Europe/Vienna)", "Europe/Vienna"), + ("Poland (Europe/Warsaw)", "Europe/Warsaw"), + ("Czech Republic (Europe/Prague)", "Europe/Prague"), + ("Greece (Europe/Athens)", "Europe/Athens"), + ("Turkey (Europe/Istanbul)", "Europe/Istanbul"), + ("Romania (Europe/Bucharest)", "Europe/Bucharest"), + ("Sweden (Europe/Stockholm)", "Europe/Stockholm"), + ("Norway (Europe/Oslo)", "Europe/Oslo"), + ("Denmark (Europe/Copenhagen)", "Europe/Copenhagen"), + ("Finland (Europe/Helsinki)", "Europe/Helsinki"), + ("Russia Moscow (Europe/Moscow)", "Europe/Moscow"), + ("Russia Yekaterinburg (Asia/Yekaterinburg)", "Asia/Yekaterinburg"), + ("Russia Novosibirsk (Asia/Novosibirsk)", "Asia/Novosibirsk"), + ("Russia Vladivostok (Asia/Vladivostok)", "Asia/Vladivostok"), + # Middle East & Central Asia + ("UAE/Dubai (Asia/Dubai)", "Asia/Dubai"), + ("Saudi Arabia (Asia/Riyadh)", "Asia/Riyadh"), + ("Israel (Asia/Jerusalem)", "Asia/Jerusalem"), + ("Iran (Asia/Tehran)", "Asia/Tehran"), + ("Pakistan (Asia/Karachi)", "Asia/Karachi"), + ("Afghanistan (Asia/Kabul)", "Asia/Kabul"), + ("Kazakhstan (Asia/Almaty)", "Asia/Almaty"), + # South Asia + ("India (Asia/Kolkata)", "Asia/Kolkata"), + ("Sri Lanka (Asia/Colombo)", "Asia/Colombo"), + ("Bangladesh (Asia/Dhaka)", "Asia/Dhaka"), + ("Nepal (Asia/Kathmandu)", "Asia/Kathmandu"), + # Southeast Asia + ("Thailand (Asia/Bangkok)", "Asia/Bangkok"), + ("Vietnam (Asia/Ho_Chi_Minh)", "Asia/Ho_Chi_Minh"), + ("Myanmar (Asia/Yangon)", "Asia/Yangon"), + ("Malaysia (Asia/Kuala_Lumpur)", "Asia/Kuala_Lumpur"), + ("Singapore (Asia/Singapore)", "Asia/Singapore"), + ("Indonesia West (Asia/Jakarta)", "Asia/Jakarta"), + ("Indonesia Central (Asia/Makassar)", "Asia/Makassar"), + ("Indonesia East (Asia/Jayapura)", "Asia/Jayapura"), + ("Philippines (Asia/Manila)", "Asia/Manila"), + # East Asia + ("China (Asia/Shanghai)", "Asia/Shanghai"), + ("Hong Kong (Asia/Hong_Kong)", "Asia/Hong_Kong"), + ("Taiwan (Asia/Taipei)", "Asia/Taipei"), + ("Japan (Asia/Tokyo)", "Asia/Tokyo"), + ("Korea (Asia/Seoul)", "Asia/Seoul"), + ("Mongolia (Asia/Ulaanbaatar)", "Asia/Ulaanbaatar"), + # Oceania + ("Australia Western (Australia/Perth)", "Australia/Perth"), + ("Australia Central (Australia/Adelaide)", "Australia/Adelaide"), + ("Australia Eastern (Australia/Sydney)", "Australia/Sydney"), + ("Australia Queensland (Australia/Brisbane)", "Australia/Brisbane"), + ("New Zealand (Pacific/Auckland)", "Pacific/Auckland"), + ("Fiji (Pacific/Fiji)", "Pacific/Fiji"), + ("Papua New Guinea (Pacific/Port_Moresby)", "Pacific/Port_Moresby"), + # Africa + ("South Africa (Africa/Johannesburg)", "Africa/Johannesburg"), + ("Egypt (Africa/Cairo)", "Africa/Cairo"), + ("Nigeria (Africa/Lagos)", "Africa/Lagos"), + ("Kenya (Africa/Nairobi)", "Africa/Nairobi"), + ("Morocco (Africa/Casablanca)", "Africa/Casablanca"), + ("Ethiopia (Africa/Addis_Ababa)", "Africa/Addis_Ababa"), + ] + + # Filter timezones based on what user is typing + filtered = [ + app_commands.Choice(name=name, value=value) + for name, value in all_timezones + if current.lower() in name.lower() + ] + + return filtered[:25] # Discord limit of 25 at a time + + @app_commands.command(name="setbirthday", description="Set your birthday 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): + try: + datetime.strptime(date, "%m-%d") + except ValueError: + await interaction.response.send_message("Invalid date format! Use MM-DD.", ephemeral=True) + return + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + INSERT INTO birthdays (user_id, birthday, timezone) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET birthday=excluded.birthday, timezone=excluded.timezone + """, (interaction.user.id, date, timezone)) + conn.commit() + conn.close() + + await interaction.response.send_message(f"🎂 Birthday set to '{date}' in timezone '{timezone}'!", 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 any(role.id == ADMIN_ROLE_ID for role in member.roles): + 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 or birthdays for a specific month.") + @app_commands.describe(month="Optional: Specify a month (1-12) to see birthdays for that month") + async def listbirthdays(self, interaction: discord.Interaction, month: int = None): + if month is not None and (month < 1 or month > 12): + await interaction.response.send_message("Invalid month! Please use a number between 1 and 12.", ephemeral=True) + return + + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT user_id, birthday from birthdays") + rows = c.fetchall() + conn.close() + + if not rows: + await interaction.response.send_message("No birthdays have been set yet!", ephemeral=True) + return + + now = datetime.now(timezone.utc) + + if month is not None: + # Filter by specific month + birthdays_list = [] + for user_id, date_str in rows: + bday_month, day = map(int, date_str.split("-")) + if bday_month == month: + display_date = datetime(now.year, bday_month, day, tzinfo=timezone.utc) + birthdays_list.append((user_id, display_date, day)) + + if not birthdays_list: + month_name = datetime(now.year, month, 1).strftime('%B') + await interaction.response.send_message(f"No birthdays in {month_name}!", ephemeral=True) + return + + # Sort by day of month + birthdays_list.sort(key=lambda x: x[2]) + month_name = datetime(now.year, month, 1).strftime('%B') + + lines = [] + for user_id, date, _ in birthdays_list: + user = interaction.guild.get_member(user_id) + name = user.display_name if user else f"User {user_id}" + lines.append(f"**{name}** - {date.strftime('%B %d')}") + + embed = discord.Embed( + title=f"🎂 Birthdays in {month_name}", + description="\n".join(lines), + color=discord.Color.magenta() + ) + await interaction.response.send_message(embed=embed) + else: + # Show all birthdays organized by month + birthdays_by_month = {} + for user_id, date_str in rows: + bday_month, day = map(int, date_str.split("-")) + if bday_month not in birthdays_by_month: + birthdays_by_month[bday_month] = [] + display_date = datetime(now.year, bday_month, day, tzinfo=timezone.utc) + birthdays_by_month[bday_month].append((user_id, display_date, day)) + + # Sort each month by day + for month_num in birthdays_by_month: + birthdays_by_month[month_num].sort(key=lambda x: x[2]) + + # Build the embed description + lines = [] + for month_num in sorted(birthdays_by_month.keys()): + month_name = datetime(now.year, month_num, 1).strftime('%B') + lines.append(f"\n**{month_name}**") + for user_id, date, _ in birthdays_by_month[month_num]: + user = interaction.guild.get_member(user_id) + name = user.display_name if user else f"User {user_id}" + lines.append(f" • {name} - {date.strftime('%B %d')}") + + embed = discord.Embed( + title="🎂 All Birthdays", + description="\n".join(lines), + color=discord.Color.magenta() + ) + await interaction.response.send_message(embed=embed) + + @tasks.loop(minutes=1) + async def check_birthdays(self): + now_utc = datetime.now(timezone.utc).replace(second = 0, microsecond = 0) + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT user_id, birthday, timezone FROM birthdays") + users = c.fetchall() + + for user_id, date_str, timezone_str in users: + try: + tz = pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + continue + + now_local = datetime.now(pytz.utc).astimezone(tz) + if now_local.hour == 0 and now_local.minute == 0: + month, day = map(int, date_str.split("-")) + if month == now_local.month and day == now_local.day: + for guild in self.bot.guilds: + member = guild.get_member(user_id) + if not member: + continue + + c.execute("SELECT channel_id FROM guild_settings WHERE guild_id=?", (guild.id,)) + row = c.fetchone() + if not row: + continue + channel_id = row[0] + channel= guild.get_channel(channel_id) + if channel: + try: + await channel.send(f"🎉 Happy Birthday, {member.mention}! 🎂") + except Exception: + pass + + conn.close() + + @check_birthdays.before_loop + async def before_check_birthdays(self): + await self.bot.wait_until_ready() + +async def setup(bot): + await bot.add_cog(Birthday(bot)) \ No newline at end of file diff --git a/bot.py b/bot.py index d00ac84..d9e8748 100644 --- a/bot.py +++ b/bot.py @@ -68,6 +68,8 @@ async def on_ready(): await load_cogs("sparkle") await load_cogs("image") await load_cogs("suggestion") + await load_cogs("birthday") + # Sync slash commands after loading cogs @@ -97,7 +99,8 @@ async def reload(ctx): await load_cogs("sparkle") await load_cogs("image") await load_cogs("suggestion") - + await load_cogs("birthday") + try: synced = await bot.tree.sync() await ctx.send(f"Cogs reloaded successfully! Synced {len(synced)} slash commands.") diff --git a/commands/ping.py b/commands/ping.py index fe407a8..a8dd544 100644 --- a/commands/ping.py +++ b/commands/ping.py @@ -1,14 +1,32 @@ import discord from discord.ext import commands from discord import app_commands +import time class Ping(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - - @app_commands.command(name="ping", description="Replies with Pong!") + + @app_commands.command(name="ping", description="Check the bot's latency") async def ping(self, interaction: discord.Interaction): - await interaction.response.send_message("Pong!") + # Measure API latency (time to respond to the interaction) + start_time = time.perf_counter() + await interaction.response.send_message("Pinging...") + end_time = time.perf_counter() + api_latency = round((end_time - start_time) * 1000) + + # Get WebSocket latency + ws_latency = round(self.bot.latency * 1000) + + # Edit the message with the results + embed = discord.Embed( + title="Pong!", + color=discord.Color.green() + ) + embed.add_field(name="WebSocket Latency", value=f"{ws_latency}ms", inline=True) + embed.add_field(name="API Latency", value=f"{api_latency}ms", inline=True) + + await interaction.edit_original_response(content=None, embed=embed) async def setup(bot: commands.Bot): await bot.add_cog(Ping(bot)) \ No newline at end of file