Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion BotSecrets.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
"GeocodeKey": "",
"AzureTranslateKey": "",
"ClassArchiveCategoryIds": [],
"ClassNotifsChannelId": ""
"ClassNotifsChannelId": "",
"BirthdayRoleId": 0,
"BirthdayCooldownInDays": 30,
"BirthdayChannelId": 0,
"BirthdayAnnouncementMode": 2
}
66 changes: 63 additions & 3 deletions bot/bot_secrets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os
import logging
from typing import Optional

from bot.errors import ConfigAccessError

Expand All @@ -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: 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:
Expand Down Expand Up @@ -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: int | None) -> 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: 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

@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: int | None) -> 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: int | None) -> 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)

Expand All @@ -190,12 +243,16 @@ 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")

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 = [
Expand All @@ -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")


Expand Down
164 changes: 164 additions & 0 deletions bot/cogs/birthday_cog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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 True or today - datetime.fromisoformat(
birthday.last_congratulated.split('.')[0]) > timedelta(hours=24):
age_format = ' '
if birthday.year:
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)}!")

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)
11 changes: 11 additions & 0 deletions bot/data/CreateTables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.

-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
Expand Down
56 changes: 56 additions & 0 deletions bot/data/birthday_repository.py
Original file line number Diff line number Diff line change
@@ -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 []
11 changes: 11 additions & 0 deletions bot/models/birthday_model.py
Original file line number Diff line number Diff line change
@@ -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