From c1ed6d7baf07a9ccb650f37b476cb68165340f19 Mon Sep 17 00:00:00 2001 From: Janet Taylor Date: Tue, 21 Nov 2023 22:41:41 -0500 Subject: [PATCH 1/2] Birthday cog --- BotSecrets.json.template | 6 +- bot/bot_secrets.py | 62 +++++++++++- bot/cogs/birthday_cog.py | 163 ++++++++++++++++++++++++++++++++ bot/data/CreateTables.sql | 11 +++ bot/data/birthday_repository.py | 56 +++++++++++ bot/models/birthday_model.py | 11 +++ 6 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 bot/cogs/birthday_cog.py create mode 100644 bot/data/birthday_repository.py create mode 100644 bot/models/birthday_model.py diff --git a/BotSecrets.json.template b/BotSecrets.json.template index b5c2766..b7fa3be 100644 --- a/BotSecrets.json.template +++ b/BotSecrets.json.template @@ -10,5 +10,9 @@ "GeocodeKey": "", "AzureTranslateKey": "", "ClassArchiveCategoryIds": [], - "ClassNotifsChannelId": "" + "ClassNotifsChannelId": "", + "BirthdayRoleId": 0, + "BirthdayCooldownInDays": 30, + "BirthdayChannelId": 0, + "BirthdayAnnouncementMode": 2 } diff --git a/bot/bot_secrets.py b/bot/bot_secrets.py index 4f3f395..640f2bc 100644 --- a/bot/bot_secrets.py +++ b/bot/bot_secrets.py @@ -1,6 +1,7 @@ import json import os import logging +from typing import Optional from bot.errors import ConfigAccessError @@ -21,6 +22,10 @@ def __init__(self) -> None: self._error_log_channel_ids: list[int] | None = None self._class_archive_category_ids: list[int] | None = None self._class_notifs_channel_id: int | None = None + self._birthday_role_id: Optional[int] = None + self._birthday_cooldown_in_days: Optional[int] = None + self._birthday_channel_id: Optional[int] = None + self._birthday_announcement_mode: Optional[int] = None @property def bot_token(self) -> str: @@ -175,6 +180,54 @@ def class_notifs_channel_id(self, value: int | None) -> None: raise ConfigAccessError("class_notifs_channel_id has already been initialized") self._class_notifs_channel_id = value + @property + def birthday_role_id(self) -> int: + if not self._birthday_role_id: + raise ConfigAccessError("birthday_role_id has not been initialized") + return self._birthday_role_id + + @birthday_role_id.setter + def birthday_role_id(self, value: Optional[int]) -> None: + if self._birthday_role_id: + raise ConfigAccessError("birthday_role_id has already been initialized") + self._birthday_role_id = value + + @property + def birthday_cooldown_in_days(self) -> int: + if not self._birthday_cooldown_in_days: + raise ConfigAccessError("birthday_cooldown_in_days has not been initialized") + return self._birthday_cooldown_in_days + + @birthday_cooldown_in_days.setter + def birthday_cooldown_in_days(self, value: Optional[int]) -> None: + if self._birthday_cooldown_in_days: + raise ConfigAccessError("birthday_cooldown_in_days has already been initialized") + self._birthday_cooldown_in_days = value + + @property + def birthday_channel_id(self) -> int: + if not self._birthday_channel_id: + raise ConfigAccessError("birthday_channel_id has not been initialized") + return self._birthday_channel_id + + @birthday_channel_id.setter + def birthday_channel_id(self, value: Optional[int]) -> None: + if self._birthday_channel_id: + raise ConfigAccessError("birthday_channel_id has already been initialized") + self._birthday_channel_id = value + + @property + def birthday_announcement_mode(self) -> int: + if not self._birthday_announcement_mode: + raise ConfigAccessError("birthday_announcement_mode has not been initialized") + return self._birthday_announcement_mode + + @birthday_announcement_mode.setter + def birthday_announcement_mode(self, value: Optional[int]) -> None: + if self._birthday_announcement_mode: + raise ConfigAccessError("birthday_announcement_mode has already been initialized") + self._birthday_announcement_mode = value + def load_development_secrets(self, lines: str) -> None: secrets = json.loads(lines) @@ -190,6 +243,10 @@ def load_development_secrets(self, lines: str) -> None: self.azure_translate_key = secrets["AzureTranslateKey"] self.class_archive_category_ids = secrets["ClassArchiveCategoryIds"] self.class_notifs_channel_id = secrets["ClassNotifsChannelId"] + self.birthday_role_id = secrets["BirthdayRoleId"] + self.birthday_channel_id = secrets["BirthdayChannelId"] + self.birthday_cooldown_in_days = secrets["BirthdayCooldownInDays"] + self.birthday_announcement_mode = secrets["BirthdayAnnouncementMode"] log.info("Bot Secrets Loaded") @@ -214,7 +271,10 @@ def load_production_secrets(self) -> None: int(n) for n in os.environ.get("CLASS_ARCHIVE_CATEGORY_IDS").split(",") # type: ignore ] self.class_notifs_channel_id = int(os.environ.get("CLASS_NOTIFS_CHANNEL_ID")) # type: ignore - + self.birthday_role_id = int(os.getenv("BirthdayRoleId")) # type: ignore + self.birthday_channel_id = int(os.getenv("BirthdayChannelId")) # type: ignore + self.birthday_cooldown_in_days = int(os.getenv("BirthdayCooldownInDays")) # type: ignore + self.birthday_announcement_mode = int(os.getenv("BirthdayAnnouncementMode")) # type: ignore log.info("Production keys loaded") diff --git a/bot/cogs/birthday_cog.py b/bot/cogs/birthday_cog.py new file mode 100644 index 0000000..b1633fc --- /dev/null +++ b/bot/cogs/birthday_cog.py @@ -0,0 +1,163 @@ +import logging +from datetime import timedelta, datetime +from enum import Enum +from typing import Optional + +import discord +from discord.ext import commands, tasks + +import bot.extensions as ext +from bot import bot_secrets +from bot.consts import Colors +from bot.data.birthday_repository import BirthdayRepository +from bot.models.birthday_model import Birthday +from bot.utils.helpers import strtodt, error_embed + +log = logging.getLogger(__name__) + + +class AnnouncementMode(Enum): + DM_ONLY = 0 + CHANNEL_ONLY = 1 + DM_AND_CHANNEL = 2 + + +class BirthdayCog(commands.Cog): + def __init__(self, bot): + self.bot: commands.Bot = bot + self.repo = BirthdayRepository() + self.birthday_role: Optional[int] = bot_secrets.secrets.birthday_role_id + self.cooldown = timedelta(days=bot_secrets.secrets.birthday_cooldown_in_days) + self.birthday_channel: Optional[int] = bot_secrets.secrets.birthday_channel_id + self.announcement_mode: AnnouncementMode = AnnouncementMode(bot_secrets.secrets.birthday_announcement_mode) + self.check_birthdays.start() + + def cog_unload(self) -> None: + self.check_birthdays.cancel() + + def get_birthday_this_year(self, birthday: Birthday): + return datetime.strptime(f"{birthday.month} {birthday.day} {datetime.now().year}", "%m %d %Y") + + def get_age(self, birthday: Birthday): + birthday_this_year = self.get_birthday_this_year(birthday) + full_birthday = datetime.strptime(f"{birthday.month} {birthday.day} {birthday.year}", "%m %d %Y") + return (birthday_this_year - full_birthday).days // 365 + + def get_ordinal_suffix(self, day: int) -> str: + return {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') if day not in (11, 12, 13) else 'th' + + @tasks.loop(seconds=86400) + async def check_birthdays(self): + if len(self.bot.guilds) == 0: + self.check_birthdays.restart() + return + today = datetime.now() + birthdays = await self.repo.get_todays_birthdays() + guild = self.bot.guilds[0] + birthday_role = [role for role in await guild.fetch_roles() if role.id == self.birthday_role][0] + for non_birthday in await self.repo.get_non_birthdays(): + member: discord.Member = await guild.fetch_member(non_birthday.member_id) + await member.remove_roles(birthday_role, reason="No longer birthday") + + for birthday in birthdays: + if not birthday.last_congratulated or today - datetime.fromisoformat( + birthday.last_congratulated.split('.')[0]) > timedelta(hours=24): + age_format = ' ' + if birthday.year: + age_format = f" {self.get_age(birthday)}{self.get_ordinal_suffix(birthday.day)} " + member: discord.Member = await guild.fetch_member(birthday.member_id) + embed = discord.Embed(color=Colors.Purple, title="⭐ WE HAVE A BIRTHDAY! ⭐", + description=f"The Clemson CPSC discord wants to wish you a very happy{age_format}birthday today, {str(member)}!") + + if member.avatar: + embed.set_footer(text=str(member), icon_url=member.avatar.url) + embed.set_thumbnail(url=member.avatar.url) + if self.announcement_mode == AnnouncementMode.CHANNEL_ONLY or self.announcement_mode == AnnouncementMode.DM_AND_CHANNEL: + channel = await self.bot.fetch_channel(self.birthday_channel) + await channel.send(embed=embed) + if self.announcement_mode == AnnouncementMode.DM_ONLY or self.announcement_mode == AnnouncementMode.DM_AND_CHANNEL: + await member.send(embed=embed) + if self.birthday_role: + await member.add_roles(birthday_role, reason="It is their birthday") + await self.repo.update_last_congratulated(birthday.member_id) + + @ext.group(case_insensitive=True, + aliases=["bd"]) + @ext.long_help('Commands for managing member birthdays.') + @ext.short_help('birthday commands') + @ext.example('birthday set') + async def birthday(self, ctx: commands.Context, target: Optional[discord.Member]): + if not ctx.invoked_subcommand: + await ctx.invoke(self.bot.get_command('birthday view'), target=target) + + @birthday.command(aliases=['view']) + @ext.long_help('View birthday for a user. Provide no arguments to view your own set birthday.') + @ext.short_help('view birthdays') + @ext.example(('birthday view', 'birthday view @SockBot')) + async def view_birthday(self, ctx: commands.Context, target: Optional[discord.Member] = None): + if not target: + target = ctx.author + birthday = await self.repo.get_birthday(target.id) + embed = discord.Embed(color=Colors.Error if not birthday else Colors.Purple, title='⭐ Birthday ⭐') + if target.avatar: + embed.set_thumbnail(url=target.avatar.url) + embed.set_footer(text=f"Command called by {ctx.author.name}", icon_url=ctx.author.avatar.url) + if not birthday: + embed.add_field(name='No Birthday Found', + value=f"The user '{target.name}' does not have a birthday set in the bot.") + else: + + message = f"{target.name} has a birthday on {birthday.month}/{birthday.day}! " + if birthday.year: + message += f"(They {'will be turning' if self.get_birthday_this_year(birthday) > datetime.now() else 'turned'} {self.get_age(birthday)} this year!)" + embed.add_field(name='Member Birthday', value=message) + await ctx.send(embed=embed) + + @birthday.command(aliases=['set', 'add']) + @ext.long_help( + 'Set your birthday in the bot. Either full month names or month numbers are valid.' + ) + @ext.short_help('Set your birthday') + @ext.example(('birthday 11 9', 'birthday november 9', 'birthday 11 9 2000')) + async def set_birthday(self, ctx: commands.Context, month: int | str, day: int, year: Optional[int]): + if isinstance(month, str): + for format in ["%B", "%b"]: + try: + month = datetime.strptime(format, month.lower()).month + except ValueError: + pass + if not isinstance(month, int): + await ctx.send(embed=error_embed(ctx.author, description=f"Invalid month.")) + return + try: + if year: + datetime.strptime(f"{month} {day} {year}", "%m %d %Y") + else: + datetime.strptime(f"{month} {day}", "%m %d") + except ValueError: + await ctx.send(embed=error_embed(ctx.author, description=f"Invalid day or year.")) + return + existing = await self.repo.get_birthday(ctx.author.id) + if existing: + existing.last_used = strtodt(existing.last_used.split('.')[0]) + cmd_valid = existing.last_used + self.cooldown + if cmd_valid < datetime.now(): + await self.repo.update_birthday(ctx.author.id, month, day, year) + else: + await ctx.send(embed=error_embed(ctx.author, + description=f"You are on cooldown. You can use this command again in {(cmd_valid - datetime.now()).days} days.")) + return + else: + await self.repo.add_birthday(ctx.author.id, month, day, year) + + embed = discord.Embed(color=Colors.Purple, title='⭐ Birthday Updated ⭐', + description=f"Successfully changed birthday to {month}/{day}{'/' + str(year) if year else ''}!") + if ctx.author.avatar: + embed.set_thumbnail(url=ctx.author.avatar.url) + embed.set_footer(text=ctx.author.name, icon_url=ctx.author.avatar.url) + await ctx.send(embed=embed) + + +async def setup(bot): + cog = BirthdayCog(bot) + await bot.add_cog(cog) diff --git a/bot/data/CreateTables.sql b/bot/data/CreateTables.sql index 82b83f4..23e7720 100644 --- a/bot/data/CreateTables.sql +++ b/bot/data/CreateTables.sql @@ -65,6 +65,17 @@ CREATE TABLE IF NOT EXISTS ClassTA ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS Birthdays +( + member_id INTEGER PRIMARY KEY, + month INTEGER NOT NULL, + day INTEGER NOT NULL, + year INTEGER, + last_used INTEGER, -- unix timestamp + last_congratulated INTEGER -- unix timestamp to not resend messages if bot restarts that same day +); + + -- Populate ClassSemester table, if the values do not exist. -- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! diff --git a/bot/data/birthday_repository.py b/bot/data/birthday_repository.py new file mode 100644 index 0000000..b160ce6 --- /dev/null +++ b/bot/data/birthday_repository.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Optional, List + +import aiosqlite + +from bot.data.base_repository import BaseRepository +from bot.models.birthday_model import Birthday + + +class BirthdayRepository(BaseRepository): + + async def get_birthday(self, member_id: int) -> Optional[Birthday]: + try: + async with aiosqlite.connect(self.resolved_db_path) as db: + return await self.fetch_first_as_class( + await db.execute("SELECT * FROM Birthdays WHERE member_id = ?", (member_id,))) + except TypeError: + return + + async def add_birthday(self, member_id: int, month: int, day: int, year: Optional[int] = None) -> None: + async with aiosqlite.connect(self.resolved_db_path) as db: + await db.execute("INSERT INTO Birthdays(member_id, month, day, year, last_used) VALUES (?, ?, ?, ?, ?)", + (member_id, month, day, year, datetime.now())) + await db.commit() + + async def update_birthday(self, member_id: int, month: int, day: int, year: Optional[int] = None) -> None: + async with aiosqlite.connect(self.resolved_db_path) as db: + await db.execute("UPDATE Birthdays SET month = ?, day = ?, year = ?, last_used = ? WHERE member_id = ?", + (month, day, year, datetime.now(), member_id)) + await db.commit() + + async def update_last_congratulated(self, member_id: int) -> None: + async with aiosqlite.connect(self.resolved_db_path) as db: + await db.execute("UPDATE Birthdays SET last_congratulated = ? WHERE member_id = ?", + (datetime.now(), member_id)) + await db.commit() + + async def get_todays_birthdays(self) -> List[Birthday]: + try: + async with aiosqlite.connect(self.resolved_db_path) as db: + today = datetime.now() + cursor = await db.execute("SELECT * FROM Birthdays WHERE month = ? AND day = ?", + (today.month, today.day)) + return await self.fetch_all_as_class(cursor) + except TypeError: + return [] + + async def get_non_birthdays(self) -> List[Birthday]: + try: + async with aiosqlite.connect(self.resolved_db_path) as db: + today = datetime.now() + cursor = await db.execute("SELECT * FROM Birthdays WHERE month != ? OR day != ?", + (today.month, today.day)) + return await self.fetch_all_as_class(cursor) + except TypeError: + return [] diff --git a/bot/models/birthday_model.py b/bot/models/birthday_model.py new file mode 100644 index 0000000..b9ec346 --- /dev/null +++ b/bot/models/birthday_model.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class Birthday: + member_id: int + month: int + day: int + year: int + last_used: str + last_congratulated: str From 60b240c8264f83bf2846e4183c77b49100dabbd2 Mon Sep 17 00:00:00 2001 From: Janet Taylor Date: Wed, 22 Nov 2023 17:10:07 -0500 Subject: [PATCH 2/2] swap types --- bot/bot_secrets.py | 20 ++++++++++---------- bot/cogs/birthday_cog.py | 5 +++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/bot_secrets.py b/bot/bot_secrets.py index 640f2bc..a6575a9 100644 --- a/bot/bot_secrets.py +++ b/bot/bot_secrets.py @@ -22,10 +22,10 @@ def __init__(self) -> None: self._error_log_channel_ids: list[int] | None = None self._class_archive_category_ids: list[int] | None = None self._class_notifs_channel_id: int | None = None - self._birthday_role_id: Optional[int] = None - self._birthday_cooldown_in_days: Optional[int] = None - self._birthday_channel_id: Optional[int] = None - self._birthday_announcement_mode: Optional[int] = None + self._birthday_role_id: int | None = None + self._birthday_cooldown_in_days: int | None = None + self._birthday_channel_id: int | None = None + self._birthday_announcement_mode: int | None = None @property def bot_token(self) -> str: @@ -187,7 +187,7 @@ def birthday_role_id(self) -> int: return self._birthday_role_id @birthday_role_id.setter - def birthday_role_id(self, value: Optional[int]) -> None: + def birthday_role_id(self, value: int | None) -> None: if self._birthday_role_id: raise ConfigAccessError("birthday_role_id has already been initialized") self._birthday_role_id = value @@ -199,7 +199,7 @@ def birthday_cooldown_in_days(self) -> int: return self._birthday_cooldown_in_days @birthday_cooldown_in_days.setter - def birthday_cooldown_in_days(self, value: Optional[int]) -> None: + def birthday_cooldown_in_days(self, value: int | None) -> None: if self._birthday_cooldown_in_days: raise ConfigAccessError("birthday_cooldown_in_days has already been initialized") self._birthday_cooldown_in_days = value @@ -211,7 +211,7 @@ def birthday_channel_id(self) -> int: return self._birthday_channel_id @birthday_channel_id.setter - def birthday_channel_id(self, value: Optional[int]) -> None: + def birthday_channel_id(self, value: int | None) -> None: if self._birthday_channel_id: raise ConfigAccessError("birthday_channel_id has already been initialized") self._birthday_channel_id = value @@ -223,7 +223,7 @@ def birthday_announcement_mode(self) -> int: return self._birthday_announcement_mode @birthday_announcement_mode.setter - def birthday_announcement_mode(self, value: Optional[int]) -> None: + def birthday_announcement_mode(self, value: int | None) -> None: if self._birthday_announcement_mode: raise ConfigAccessError("birthday_announcement_mode has already been initialized") self._birthday_announcement_mode = value @@ -251,8 +251,8 @@ def load_development_secrets(self, lines: str) -> None: log.info("Bot Secrets Loaded") def load_production_secrets(self) -> None: - - # Ignore these type errors, mypy doesn't know how to handle properties that return narrower types then they are assigned too + # Ignore these type errors, mypy doesn't know how to handle properties that return narrower types then they + # are assigned too self.bot_token = os.environ.get("BOT_TOKEN") # type: ignore self.bot_prefix = os.environ.get("BOT_PREFIX") # type: ignore self.startup_log_channel_ids = [ diff --git a/bot/cogs/birthday_cog.py b/bot/cogs/birthday_cog.py index b1633fc..1ae5caf 100644 --- a/bot/cogs/birthday_cog.py +++ b/bot/cogs/birthday_cog.py @@ -60,11 +60,12 @@ async def check_birthdays(self): await member.remove_roles(birthday_role, reason="No longer birthday") for birthday in birthdays: - if not birthday.last_congratulated or today - datetime.fromisoformat( + if not birthday.last_congratulated or True or today - datetime.fromisoformat( birthday.last_congratulated.split('.')[0]) > timedelta(hours=24): age_format = ' ' if birthday.year: - age_format = f" {self.get_age(birthday)}{self.get_ordinal_suffix(birthday.day)} " + age = self.get_age(birthday) + age_format = f" {age}{self.get_ordinal_suffix(age)} " member: discord.Member = await guild.fetch_member(birthday.member_id) embed = discord.Embed(color=Colors.Purple, title="⭐ WE HAVE A BIRTHDAY! ⭐", description=f"The Clemson CPSC discord wants to wish you a very happy{age_format}birthday today, {str(member)}!")