Files
dakiny-bot/utils.py
T
imcompiling 04c44bc470 only trigger updating clan messages in updated directories.
Update clan info message if the template was updated.
2024-05-16 21:37:10 -04:00

590 lines
18 KiB
Python

"""
File contains util functions.
"""
import os
from datetime import datetime
from typing import Iterable
import nextcord
from sqlalchemy import select, true, update
from sqlalchemy.orm import Session
from config import DakinyConfig
from database_setup.sql_data_classes import (
CouncilMembers,
Messages,
ParticipationPoints,
Votes,
engine,
)
NOMINEE_SPLITTER = "========================================\n\n"
REPO_DIR = "./dakiny-messages"
MESSAGES_DIR = f"{REPO_DIR}/messages"
TEMPLATES_DIR = f"{REPO_DIR}/message_templates"
def get_datetime(day: str) -> datetime:
"""
Takes in a string month and day and returns a datetime object of that Month/Day this year.
Args:
day (str): Month/Day in that format.
Returns:
datetime: Datetime representation of Month/Day.
"""
year = datetime.today().year
month = int(day.split("/")[0])
day = int(day.split("/")[1])
# ensure that year rolls over if current month is December and the next month is less than 12.
if datetime.today().month == 12 and month < 12:
year += 1
if datetime.today().month == 1 and month > 1:
year -= 1
return datetime(year, month, day)
def get_election_cycle_name(config: DakinyConfig) -> str:
"""
Builds the unique name of the current election cycle
Args:
config (DakinyConfig): the config used to know the election cycle.
Returns:
str: the name of the election cycle
"""
cycle = get_current_election_cycle(config)
current_cycle = config.election_cycles[cycle]
return f"vote-{get_datetime(current_cycle.term_start).year}-{current_cycle.name}"
def get_cycle_name(config: DakinyConfig) -> str:
"""
Builds the name of the current cycle/quarter
Args:
config (DakinyConfig): the config used to know the election cycle.
Returns:
str: the name of the cycle.
"""
cycle = get_current_cycle(config)
current_cycle = config.election_cycles[cycle]
return f"{get_datetime(current_cycle.term_start).year}-{current_cycle.name}"
def get_dm_election_name(config: DakinyConfig) -> str:
"""
creates the name of the discord manager election
Args:
config (DakinyConfig): Config sent to get_election_cycle_name
Returns:
str: the name of the election
"""
return f"{get_election_cycle_name(config)}-dm"
def get_cl_election_name(config: DakinyConfig) -> str:
"""
creates the name of the clan leader election
Args:
config (DakinyConfig): Config sent to get_election_cycle_name
Returns:
str: the name of the election
"""
return f"{get_election_cycle_name(config)}-cl"
def get_current_election_cycle(config: DakinyConfig) -> int:
"""
Returns index for active election. An election is considered active between the election start and the term start.
Args:
config (DakinyConfig): Config object to use
Returns:
int: cycle index for active election. None for no active cycle
"""
today = datetime.today()
for cycle, election in enumerate(config.election_cycles):
if (
today.date() >= get_datetime(election.election_start).date()
and today.date() <= get_datetime(election.term_start).date()
):
return cycle
return None
def get_current_cycle(config: DakinyConfig) -> int:
"""
Returns the index for the current cycle/quarter. Defined as between term start and end. Shouldn't return none
Args:
config (DakinyConfig): Config object to use
Returns:
int: cycle index for current cycle.
"""
today = datetime.today()
for cycle, election in enumerate(config.election_cycles):
if (
today.date() >= get_datetime(election.term_start).date()
and today.date() <= get_datetime(election.term_end).date()
):
return cycle
return None
def build_votes_str(config: DakinyConfig, guild: nextcord.Guild) -> str:
"""
Creates a string that contains all nominees and their votes to be sent as a discord message.
Args:
config (DakinyConfig): Daking config object
guild (nextcord.Guild): The guild object. Used to get display names for sorting.
Returns:
str: String that contains all nominees and their votes.
"""
votes: dict[int, int] = {}
with Session(engine) as session:
stmt = select(CouncilMembers).where(
CouncilMembers.ElectionCycle == get_election_cycle_name(config)
)
for nominee in sorted(
session.scalars(stmt),
key=lambda nominee: guild.get_member(nominee.MemberId).display_name,
):
votes[nominee.MemberId] = 0
stmt = select(Votes).where(
Votes.ElectionCycle == get_election_cycle_name(config)
)
for vote in session.scalars(stmt):
if vote.VotedForId is not None:
votes[vote.VotedForId] += 1
votes_str: str = "===================="
votes_str += user_vote_str(votes)
return votes_str
def build_highcouncil_votes_str(config: DakinyConfig, guild: nextcord.Guild) -> str:
"""
Creates a string that contains all high council nominees and their votes to be sent as a discord message.
Args:
config (DakinyConfig): Daking config object
Returns:
str: String that contains all nominees and their votes.
guild (nextcord.Guild): The guild object. Used to get display names for sorting.
"""
cl_votes: dict[int, int] = {}
dm_votes: dict[int, int] = {}
with Session(engine) as session:
stmt = (
select(CouncilMembers)
.where(CouncilMembers.ElectionCycle == get_election_cycle_name(config))
.filter(CouncilMembers.ClanLeaderSpeechId.is_not(None))
)
for nominee in sorted(
session.scalars(stmt),
key=lambda nominee: guild.get_member(nominee.MemberId).display_name,
):
cl_votes[nominee.MemberId] = 0
stmt = select(Votes).where(Votes.ElectionCycle == get_cl_election_name(config))
for vote in session.scalars(stmt):
if vote.VotedForId is not None:
cl_votes[vote.VotedForId] += 1
stmt = (
select(CouncilMembers)
.where(CouncilMembers.ElectionCycle == get_election_cycle_name(config))
.filter(CouncilMembers.DiscordManagerSpeechId.is_not(None))
)
for nominee in sorted(
session.scalars(stmt),
key=lambda nominee: guild.get_member(nominee.MemberId).display_name,
):
dm_votes[nominee.MemberId] = 0
stmt = select(Votes).where(Votes.ElectionCycle == get_dm_election_name(config))
for vote in session.scalars(stmt):
if vote.VotedForId is not None:
dm_votes[vote.VotedForId] += 1
# build the votes string
votes_str: str = "Clan Leader Votes\n===================="
votes_str += user_vote_str(cl_votes)
votes_str += "\n\nDiscord Manager Votes\n===================="
votes_str += user_vote_str(dm_votes)
return votes_str
def user_vote_str(votes: dict[int, int]) -> str:
"""
Creates the string representing how many votes each user has.
Args:
votes (dict[int, int]): Dict of all users and how many votes they have.
Returns:
str: The string showing names and number of votes.
"""
ret_str: str = ""
for user_id, num_votes in votes.items():
ret_str += f"\n<@{user_id}>: {'☑️' * num_votes}"
return ret_str
def decorate_nomination(user_id: int, speech: str) -> str:
"""
Decorates a nomination speech so that it sits within multiple speeches better.
Args:
user_id (int): ID of user sending the speech
speech (str): Speech from the user
Returns:
str: Decorated speech
"""
msg = f"**Clan Council Nominee: <@{user_id}>**\n"
msg += NOMINEE_SPLITTER
msg += speech
return msg
def decorate_cl_nomination(user_id: int, speech: str) -> str:
"""
Decorates a nomination speech so that it sits within multiple speeches better.
Args:
user_id (int): ID of user sending the speech
speech (str): Speech from the user
Returns:
str: Decorated speech
"""
msg = f"**Clan Leader Nominee: <@{user_id}>**\n"
msg += NOMINEE_SPLITTER
msg += speech
return msg
def decorate_dm_nomination(user_id: int, speech: str) -> str:
"""
Decorates a nomination speech so that it sits within multiple speeches better.
Args:
user_id (int): ID of user sending the speech
speech (str): Speech from the user
Returns:
str: Decorated speech
"""
msg = f"**Discord Manager Nominee: <@{user_id}>**\n"
msg += NOMINEE_SPLITTER
msg += speech
return msg
def record_event_participation(config: DakinyConfig, users: list[str], is_member: bool):
"""
Adds participation points to all users listed. Checks if they don't exist in order to make a new entry, otherwise
adds points to the existing value.
Args:
config (DakinyConfig): Dakiny config for getting points
users (list[int]): users that participated and need points.
is_member (bool): whether the users are a discord member or not
"""
cycle = get_cycle_name(config)
participation_point_value = config.params.event_participate_points
with Session(engine) as session:
for user in users:
stmt = (
select(ParticipationPoints)
.where(ParticipationPoints.UserId == user)
.where(ParticipationPoints.Cycle == cycle)
)
points = session.scalar(stmt)
if points is None:
new_points = ParticipationPoints(
Cycle=cycle,
UserId=user,
DiscordMember=is_member,
Points=participation_point_value,
)
session.add(new_points)
else:
points.Points += participation_point_value
session.commit()
def record_event_host(config: DakinyConfig, user: int):
"""
Adds host points to the given user. Checks if they don't exist in order to make a new entry, otherwise adds points
to the existing value.
Args:
config (DakinyConfig): Dakiny config for getting points
user (int): Id of user that hosted.
"""
cycle = get_cycle_name(config)
host_point_value = config.params.event_host_points
with Session(engine) as session:
stmt = (
select(ParticipationPoints)
.where(ParticipationPoints.UserId == str(user))
.where(ParticipationPoints.Cycle == cycle)
)
points = session.scalar(stmt)
if points is None:
new_points = ParticipationPoints(
Cycle=cycle,
UserId=str(user),
DiscordMember=True,
Points=host_point_value,
)
session.add(new_points)
else:
points.Points += host_point_value
session.commit()
async def update_clan_messages(guild: nextcord.Guild, channel_updates: Iterable[str]):
"""
Updates all clan messages to contents in MESSAGES_DIR.
Does not to any git work. Is expected to be called after git is pulled.
for ever directory, sort the files by number then edits/sends the message.
Channel fetching/management is handled by another function.
Message editing and sending is handled by another function.
Args:
guild (nextcord.Guild): Guild object that all channels and messages belong to.
"""
dirs = [
dir_name
for dir_name in channel_updates
if os.path.isdir(f"{MESSAGES_DIR}/{dir_name}")
]
with Session(engine) as session:
for dir_name in dirs:
dir_path = f"{MESSAGES_DIR}/{dir_name}"
channel = await get_clan_message_channel(session, dir_name, guild)
sorted_files = sorted(
os.listdir(dir_path),
key=lambda filename: int(filename.split(".")[0]),
)
for file_name in sorted_files:
message_name = f"{dir_name}/{file_name}"
file_path = f"{dir_path}/{file_name}"
with open(file_path, "r", encoding="utf8") as reader:
await edit_clan_message(
session, channel, message_name, reader.read(2000)
)
async def get_clan_message_channel(
session: Session, channel_name: str, guild: nextcord.Guild
) -> nextcord.TextChannel:
"""
Used to obtain a channel from the guild object. Searches database for an id to get the channel. If that is not
found, then searches through all channels in the guild to find one with a matching name. If that also doesn't
turn up a channel, then creates it. Adds an entry into database for last two paths.
Assumes that there is an open session to the database.
Args:
session (Session): Open connection to database.
channel_name (str): Name of channel to obtain.
guild (nextcord.Guild): Guild object to get channel from.
Returns:
nextcord.TextChannel: Channel object with name channel_name.
"""
stmt = select(Messages.Id).where(Messages.Path == channel_name)
channel_id = session.scalar(stmt)
if channel_id is None:
ret_channel = None
# channel is not tracked. see if it exists
channels = await guild.fetch_channels()
for channel in channels:
if (
channel.name == channel_name
and channel.__class__ is nextcord.TextChannel
):
ret_channel = channel
if ret_channel is None:
# channel doesn't exist, we must create it
ret_channel = await guild.create_text_channel(
channel_name,
reason="Creating channel as required for by design of Dakiny-Messages repo.",
)
# record channel information
new_item = Messages(Path=channel_name, Id=ret_channel.id)
session.add(new_item)
session.commit()
return ret_channel
try:
return await guild.fetch_channel(channel_id)
except nextcord.NotFound:
# the channel was deleted for some reason
ret_channel = await guild.create_text_channel(
channel_name,
reason="Creating channel as required for by design of Dakiny-Messages repo.",
)
stmt = (
update(Messages)
.where(Messages.Path == channel_name)
.values(Id=ret_channel.id)
)
session.execute(stmt)
session.commit()
return ret_channel
async def edit_clan_message(
session: Session,
channel: nextcord.TextChannel,
message_name: str,
message_content: str,
):
"""
Obtains a message id from database. If no id, then need to send a new message and record the id.
If there is a message, then edit the message. If that message is not found in discord, then need to
send a new message and record the id in the database.
Args:
session (Session): Open connection to database.
channel (nextcord.TextChannel): Channel object that message belongs to.
message_name (str): Name of the message. Corresponds to the file that contains message content.
message_content (str): Content of the message to edit/send.
"""
stmt = select(Messages.Id).where(Messages.Path == message_name)
msg_id = session.scalar(stmt)
if msg_id is None:
# don't have a message for this file
msg = await channel.send(message_content)
new_message = Messages(Path=message_name, Id=msg.id)
session.add(new_message)
session.commit()
else:
try:
msg: nextcord.Message = await channel.fetch_message(msg_id)
await msg.edit(message_content)
except nextcord.NotFound:
msg = await channel.send(message_content)
stmt = (
update(Messages).where(Messages.Path == message_name).values(Id=msg.id)
)
session.execute(stmt)
session.commit()
async def edit_clan_council_info(guild: nextcord.Guild, config: DakinyConfig):
"""
Gets the winners of the elections and updates the perminant message that states the current council.
Args:
guild (nextcord.Guild): Guild object that to use to get channel.
config (DakinyConfig): Configs
"""
clan_info_channel: nextcord.TextChannel = await guild.fetch_channel(
config.params.clan_info_channel_id
)
election = get_election_cycle_name(config)
with Session(engine) as session:
stmt = (
select(CouncilMembers.MemberId)
.where(CouncilMembers.ElectionCycle == election)
.where(CouncilMembers.DiscordManager == true())
)
discord_manager = session.scalar(stmt)
stmt = (
select(CouncilMembers.MemberId)
.where(CouncilMembers.ElectionCycle == election)
.where(CouncilMembers.ClanLeader == true())
)
clan_leader = session.scalar(stmt)
stmt = (
select(CouncilMembers.MemberId)
.where(CouncilMembers.ElectionCycle == election)
.where(CouncilMembers.Elected == true())
)
council_members_strs = [
f"<@{id}>"
for id in session.scalars(stmt)
if id not in (discord_manager, clan_leader)
]
quarter = get_cycle_name(config)
with open(
f"{TEMPLATES_DIR}/council_members.txt", mode="r", encoding="utf8"
) as file:
message = file.read()
message = message.format(
quarter=quarter,
council="\n".join(council_members_strs),
clan_leader=clan_leader,
discord_manager=discord_manager,
)
await edit_clan_message(
session, clan_info_channel, "council_members.txt", message
)