diff --git a/.gitignore b/.gitignore index 2b4fd5b..351e035 100644 --- a/.gitignore +++ b/.gitignore @@ -130,7 +130,7 @@ dmypy.json # logging nextcord.log -# data file for bot +# bot files highscores_data.json highscores_config.json deleted_backups/ @@ -139,4 +139,5 @@ TODO.txt sql_tester.py conf outputs -database \ No newline at end of file +database +dakiny-messages/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index aff3029..6a485b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.10.6-slim -# install dependencies for mysqlclient -# RUN apt-get update && apt-get -y upgrade && apt-get -y install python3-dev default-libmysqlclient-dev build-essential +# install dependencies +RUN apt-get update && apt-get -y upgrade && apt-get -y install git RUN pip install --upgrade pip @@ -20,7 +20,4 @@ COPY database_setup database_setup/ RUN pip install -r requirements.txt -# remove build deps that aren't needed to run -# RUN apt-get -y remove python3-dev build-essential && apt-get -y autoremove - CMD ["python3", "-u", "./main.py"] \ No newline at end of file diff --git a/cogs/clan_messages/__init__.py b/cogs/clan_messages/__init__.py new file mode 100644 index 0000000..209e27b --- /dev/null +++ b/cogs/clan_messages/__init__.py @@ -0,0 +1,82 @@ +""" +File contains logic for creating a webhook listener, pulling from git, and updating all messages. +""" + +import os + +import nextcord +import uvicorn +from fastapi import FastAPI +from git import Repo +from nextcord.ext import commands + +from config import DakinyConfig +from utils import REPO_DIR, update_clan_messages + +app = FastAPI() +REPO_URL = "https://gitea.cheezers.org/imcompiling/dakiny-messages.git" + + +class ClanMessages(commands.Cog, name="Clan Messages"): + """ + Cog to be loaded into the bot. Used to be able to have a fastapi post listener and access to the bot at + the same time. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.config: DakinyConfig = self.bot.config + self.repo_setup() + + @commands.Cog.listener() + async def on_ready(self): + """ + Triggers when the bot is in ready state. Adds the webhook listener to FastAPI and serves. + """ + app.add_api_route("/messages/", self.update_messages, methods=["POST"]) + config = uvicorn.Config(app=app, host="0.0.0.0", port=5000, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + async def update_messages(self): + """ + Pulls from git and then calls method to update all the discord messages as described by the + files in the git repo. + """ + + self.origin.pull() + guild: nextcord.Guild = self.bot.get_guild(self.config.params.guild_id) + + await update_clan_messages(guild) + + return {"message": "Successfully updated all discord messages."} + + def repo_setup(self): + """ + Checks if the repo exists and creates repo object, clones if it does not. + """ + # check if repo has been cloned. open repo object + if os.path.isdir(REPO_DIR): + self.repo = Repo(REPO_DIR) + else: + self.repo = Repo.clone_from(REPO_URL, REPO_DIR) + + self.origin = self.repo.remote("origin") + + if self.config.params.git_branch == "test": + self.repo.create_head("test", self.origin.refs.test).set_tracking_branch( + self.origin.refs.test + ).checkout() + else: + self.repo.create_head("main", self.origin.refs.main).set_tracking_branch( + self.origin.refs.main + ).checkout() + + self.origin.pull() + + +# This function will be called when this extension is loaded. +# It is necessary to add these functions to the bot. +def setup(bot: commands.Bot): + """Adds function to bot""" + bot.add_cog(ClanMessages(bot)) diff --git a/cogs/commands/__init__.py b/cogs/commands/__init__.py index 013fef5..725f143 100644 --- a/cogs/commands/__init__.py +++ b/cogs/commands/__init__.py @@ -5,6 +5,7 @@ Cog loader. Adds all command cogs to the bot. from nextcord.ext import commands from cogs.commands.record import RecordCog +from cogs.commands.refresh import RefreshCog from cogs.commands.remove import RemoveCog from cogs.commands.request import RequestCog from cogs.commands.set import setCog @@ -20,6 +21,7 @@ def setup(bot: commands.Bot): """ bot.add_cog(RecordCog(bot)) + bot.add_cog(RefreshCog()) bot.add_cog(RemoveCog()) bot.add_cog(RequestCog(bot)) bot.add_cog(TestCog(bot)) diff --git a/cogs/commands/refresh.py b/cogs/commands/refresh.py new file mode 100644 index 0000000..429fa85 --- /dev/null +++ b/cogs/commands/refresh.py @@ -0,0 +1,34 @@ +""" +Contained commands and views for refresh commands. +""" + +import nextcord +from nextcord.ext import commands + +from utils import update_clan_messages + + +class RefreshCog(commands.Cog, name="Refresh Commands"): + """ + Class holds refresh commands. + """ + + @nextcord.slash_command( + default_member_permissions=nextcord.Permissions(administrator=True) + ) + async def refresh(self, interaction: nextcord.Interaction): + """ + Parent slash command for refresh. Never called. + """ + + @refresh.subcommand(description="Force refresh all of the bot managed messages.") + async def messages(self, interaction: nextcord.Interaction): + """ + Calls update_clan_messages() to update all messages. + + Args: + interaction (nextcord.Interaction): Interaction with the command. + """ + await interaction.response.defer() + await update_clan_messages(interaction.guild) + await interaction.followup.send("Messages have been refreshed.", ephemeral=True) diff --git a/config.py b/config.py index b779fbc..6510907 100644 --- a/config.py +++ b/config.py @@ -23,6 +23,7 @@ class Params: event_participate_points: int event_post_channel_id: int general_channel_id: int + git_branch: str guild_id: int high_council_role_id: int voting_parent_id: int diff --git a/database_setup/database.sql b/database_setup/database.sql index 10b7061..bab1494 100644 --- a/database_setup/database.sql +++ b/database_setup/database.sql @@ -46,4 +46,9 @@ CREATE TABLE ParticipationPoints ( DiscordMember boolean not null, Points int unsigned DEFAULT 0, PRIMARY KEY (Cycle, UserId) +); + +CREATE TABLE Messages ( + Path varchar(100) PRIMARY KEY, + Id bigint unsigned not null ); \ No newline at end of file diff --git a/database_setup/sql_data_classes.py b/database_setup/sql_data_classes.py index 9fa382a..007d432 100644 --- a/database_setup/sql_data_classes.py +++ b/database_setup/sql_data_classes.py @@ -12,9 +12,10 @@ Base = automap_base() Base.prepare(autoload_with=engine) # define the classes -ElectionInformation = Base.classes.ElectionInformation CouncilMembers = Base.classes.CouncilMembers -Votes = Base.classes.Votes -UserBirthday = Base.classes.UserBirthday -Presences = Base.classes.Presences +ElectionInformation = Base.classes.ElectionInformation +Messages = Base.classes.Messages ParticipationPoints = Base.classes.ParticipationPoints +Presences = Base.classes.Presences +UserBirthday = Base.classes.UserBirthday +Votes = Base.classes.Votes diff --git a/requirements.txt b/requirements.txt index 2ffe3d1..c397c41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ python-dotenv==1.0.0 pytz==2022.7.1 hydra-core==1.3.2 sqlalchemy<2.0 -pandas==1.5.2 \ No newline at end of file +pandas==1.5.2 +fastapi==0.111.0 +GitPython==3.1.43 \ No newline at end of file diff --git a/utils.py b/utils.py index ed43245..b462915 100644 --- a/utils.py +++ b/utils.py @@ -2,21 +2,25 @@ File contains util functions. """ +import os from datetime import datetime import nextcord -from sqlalchemy import select +from sqlalchemy import select, 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" def get_datetime(day: str) -> datetime: @@ -386,3 +390,121 @@ def record_event_host(config: DakinyConfig, user: int): points.Points += host_point_value session.commit() + + +async def update_clan_messages(guild: nextcord.Guild): + """ + 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 post + posting involves checking if there is a message for that particular file then editing it or creating a + new one. the message could be deleted, which would cause a new message to be sent and database updated. + + Channel fetching/management 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 os.listdir(MESSAGES_DIR) + 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: + 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(reader.read(2000)) + 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(reader.read(2000)) + except nextcord.NotFound: + msg = await channel.send(reader.read(2000)) + stmt = ( + update(Messages) + .where(Messages.Path == message_name) + .values(Id=msg.id) + ) + session.execute(stmt) + session.commit() + + +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