diff --git a/docker-compose.yaml b/docker-compose.yaml index 5fc1cc8..ad61840 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,14 +4,50 @@ services: # define services app: # main Python app image: ghcr.io/hippoprogrammer/flamebringer:main restart: always + depends_on: + db: + condition: service_healthy secrets: - bot-token - bot-config environment: # pass location of secrets to the container FLAMEBRINGER_CONFIG_FILE: /run/secrets/bot-config + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_USER: flamebringer + POSTGRES_DB: flamebringer + POSTGRES_PASSWORD: flamebringer + networks: + - db + + db: # postgres DB + hostname: db # assign a hostname for easier connection + image: postgres:18.6-alpine3.23 # use the latest Postgres Alpine image + shm_size: 128mb # define a memory limit + restart: always # restart if errors occur + environment: + POSTGRES_PASSWORD: flamebringer + POSTGRES_DB: flamebringer + POSTGRES_USER: flamebringer + volumes: # map a volume to the Postgres data directory + - db-data:/var/lib/postgresql # data + - ./sql:/docker-entrypoint-initdb.d # initialization scripts + networks: # allow the two containers to communicate + - db + healthcheck: # set up a health check to confirm when the DB is ready + test: ["CMD-SHELL", "pg_isready -U flamebringer"] + interval: 10s + timeout: 5s + retries: 5 secrets: bot-token: file: ./conf/secrets/token.conf bot-config: file: ./conf/bot.yaml + +volumes: # allow Docker to handle volume storage + db-data: + +networks: # allow Docker to handle networking + db: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2f3733e..226c303 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ py-cord[speed]==2.8.1 # Discord API wrapper +psycopg[binary,pool]==3.3.4 # Postgres connector PyYAML==6.0.3 validators==0.35.0 diff --git a/sql/00-tables.sh b/sql/00-tables.sh new file mode 100644 index 0000000..a494b0c --- /dev/null +++ b/sql/00-tables.sh @@ -0,0 +1,17 @@ +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + \c flamebringer + + CREATE TYPE PROPOSALTYPE AS ENUM ('legislative', 'constitutional', 'honorary', 'holiday', 'treaty'); + CREATE TYPE PROPOSALSTATE AS ENUM ('draft', 'debate', 'vote', 'passed', 'failed', 'repealed'); + + CREATE TABLE IF NOT EXISTS Halls ( + Ref SERIAL PRIMARY KEY, + Name TEXT NOT NULL, + Authors BIGINT ARRAY NOT NULL, + Thread BIGINT NOT NULL CHECK UNIQUE, + Type PROPOSALTYPE NOT NULL, + State PROPOSALSTATE NOT NULL, + ID TEXT CHECK UNIQUE, + Results INT ARRAY + ); +EOSQL \ No newline at end of file diff --git a/src/__main__.py b/src/__main__.py index 284f893..b10901b 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -5,6 +5,10 @@ import discord # py-cord: discord bot framework import validators # string validation import datetime # datetime handling +import psycopg_pool # db +import psycopg.errors +import psycopg.types.enum +import asyncio import cogs.private as private from yaml import safe_load as load_yaml # yaml parsing @@ -29,11 +33,142 @@ token = file.read() logger.info('Token loaded') -# create the Bot object +# load DB connection params +connection_uri = f"postgresql://{os.getenv("POSTGRES_USER")}:{os.getenv("POSTGRES_PASSWORD")}@{os.getenv("POSTGRES_HOST")}:{os.getenv("POSTGRES_PORT")}/{os.getenv("POSTGRES_DB")}" + +# create objects +class FlamebringerDB: + def __init__(self, connection_uri: str, retry_on_fail = 3) -> None: + "Initialize a basic asynchronous pool of connections to a PostgreSQL database" + self.logger = logging.getLogger(__name__) # give this instance the module-level logger + + self.retry_on_fail = retry_on_fail + + self.connection_pool = psycopg_pool.AsyncConnectionPool(conninfo = connection_uri, min_size = 2, max_size = 16, open = False) # give this instance a connection pool to the DB + self.logger.debug('Connection pool created') + async def _open_connection_pool(self) -> None: + await self.connection_pool.open() # opens the connection pool + self.logger.debug('Connection pool opened') + # register enums + try: + async with self.connection_pool.connection() as conn: + self.logger.debug('Connection fetched from pool') + psycopg.types.enum.register_enum(psycopg.types.enum.EnumInfo.fetch(conn, "PROPOSALTYPE"), conn, private.ProposalType) + psycopg.types.enum.register_enum(psycopg.types.enum.EnumInfo.fetch(conn, "PROPOSALSTATE"), conn, private.ProposalState) + except psycopg_pool.PoolTimeout as e: + self.logger.warning(e) + await self.connection_pool.check() + except psycopg.errors.Error as e: + self.logger.error(e) + async def _close_connection_pool(self) -> None: + await self.connection_pool.close() # closes the connection pool + self.logger.debug('Connection pool closed') + async def _query(self, query: str, params: tuple, autocommit = False) -> list | bool: + "Send raw SQL queries to the database" + try: + async with self.connection_pool.connection() as conn: # get a connection from the pool + await conn.set_autocommit(autocommit) + self.logger.debug('Connection fetched from pool') + async with conn.cursor() as cur: # open a cursor + self.logger.debug('Cursor opened') + + await cur.execute(query, params) + + self.logger.debug('Query executed successfully') + + results = list(await cur.fetchall()) + + self.logger.debug('Results fetched successfully') + + return results + except psycopg_pool.PoolTimeout as e: + self.logger.warning(e) + await self.connection_pool.check() + return False + except psycopg.errors.Error as e: + self.logger.error(e) + return False + async def add_proposal(self, name: str, authors: list[str], thread: int, type: private.ProposalType, state: private.ProposalState, id: str | None = None, results: list[int] | None = None) -> bool: + return bool(await self._query( + query = "INSERT INTO Halls (Name, Authors, Thread, Type, State, ID, Results) VALUES (%s, %s, %s, %s, %s, %s, %s);", + params = (name, authors, thread, type, state, id, results) + )) + async def update_proposal_with_results_and_id(self, ref: int, id: str, results: list[int]) -> bool: + return bool(await self._query( + query = "UPDATE Halls SET ID = %s, Results = %s WHERE Ref = %s;", + params = (id, results, ref) + )) + async def get_proposal_by_ref(self, ref: int) -> list: + return await self._query( + query = "SELECT 1 FROM Halls WHERE Ref = %s;", + params = (ref) + ) + async def get_proposal_by_id(self, id: str) -> list: + return await self._query( + query = "SELECT 1 FROM Halls WHERE ID = %s;", + params = (id) + ) + async def get_proposal_by_thread(self, thread: int) -> list: + return await self._query( + query = "SELECT 1 FROM Halls WHERE Thread = %s;", + params = (thread) + ) + async def delete_proposal_by_ref(self, ref: int) -> bool: + return bool(await self._query( + query = "DELETE FROM Halls WHERE Ref = %s;", + params = (ref) + )) + +class Flamebringer(discord.Bot): + def __init__(self, connection_uri: str, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.db = FlamebringerDB(connection_uri = connection_uri) + + async def on_ready() -> None: + activity = discord.Game("Warding the Flame...") + status = discord.Status.online + await bot.change_presence(activity=activity, status=status) + logger.info("Bot started, ready for interaction") + + async def on_application_command_error(ctx:discord.ApplicationContext, error:discord.DiscordException): # error handler + if type(error) is discord.ext.commands.MessageNotFound: + logger.info("Message was not found") + + embed = discord.Embed(title = "Message not Found", description = "The message provided was not found.") + logger.debug("Embed object created") + + await ctx.respond(embed = embed, ephemeral = True) + logger.info("Message not found embed sent") + else: + logger.error(error, stack_info = True, exc_info = True) + await ctx.channel.send(f'<@{private.config[ctx.guild.id]["error_ping"]}> An unspecified error occurred.') + + async def on_thread_create(thread: discord.Thread): # ping the office when a new thread is created + if thread.parent == bot.get_channel(private.config[thread.guild.id]["voting_forum_id"]): # in the correct channel + if thread.can_send(): + if thread.parent.get_tag(private.config[thread.guild.id]["debate_tag_id"]) in thread.applied_tags: + embed = discord.Embed(title = "You have submitted your proposal into debate!", description = "You may motion your proposal to vote no sooner than 48 hours after the Flamewarden (or deputy) acknowledges the proposal.") + await thread.send(content=f"<@&{"> <@&".join(map(str, private.config[thread.guild.id]["fw_announcement_role_ids"]))}>", embed=embed) + logger.info("Debate ping sent") + else: + embed = discord.Embed(title = "You have submitted your proposal!", description = "You may motion your proposal to debate at any time by modifying this thread's tags to 'In Debate'.") + await thread.send(embed=embed) + logger.info("Submission embed sent") + + async def on_thread_update(before: discord.Thread, after: discord.Thread): # ping the office when a new thread is created + if after.parent == bot.get_channel(private.config[after.guild.id]["voting_forum_id"]): # in the correct channel + if after.can_send(): + if not before.applied_tags == after.applied_tags: # if a change has actually been made + if after.parent.get_tag(private.config[after.guild.id]["debate_tag_id"]) in after.applied_tags and after.parent.get_tag(private.config[after.guild.id]["debate_tag_id"]) not in before.applied_tags: + embed = discord.Embed(title = "You have submitted your proposal into debate!", description = "You may motion your proposal to vote no sooner than 48 hours after the Flamewarden (or deputy) acknowledges the proposal.") + await after.send(content=f"<@&{"> <@&".join(map(str, private.config[after.guild.id]["fw_announcement_role_ids"]))}>", embed=embed) + logger.info("Debate ping sent") + + intents = discord.Intents.default() # we need default intents so the bot actually functions well intents.members = True # we also need members permission to calculate quorum, as that requires fetching the full member list of a role which needs the members intent intents.messages = True # we need this to read our own messages, annoyingly -bot = discord.Bot(intents = intents) # create a bot instance, with the previously set intents +bot = Flamebringer(connection_uri = connection_uri, intents = intents) # create a bot instance, with the previously set intents logger.debug("Bot object created") # load cogs @@ -48,49 +183,4 @@ if splitted[1] == '.py': # if the file is python bot.load_extension(f"cogs.{splitted[0]}") -# bot events -@bot.event -async def on_ready() -> None: - activity = discord.Game("Warding the Flame...") - status = discord.Status.online - await bot.change_presence(activity=activity, status=status) - logger.info("Bot started, ready for interaction") - -@bot.event -async def on_application_command_error(ctx:discord.ApplicationContext, error:discord.DiscordException): # error handler - if type(error) is discord.ext.commands.MessageNotFound: - logger.info("Message was not found") - - embed = discord.Embed(title = "Message not Found", description = "The message provided was not found.") - logger.debug("Embed object created") - - await ctx.respond(embed = embed, ephemeral = True) - logger.info("Message not found embed sent") - else: - logger.error(error, stack_info = True, exc_info = True) - await ctx.channel.send(f'<@{private.config[ctx.guild.id]["error_ping"]}> An unspecified error occurred.') - -@bot.event -async def on_thread_create(thread: discord.Thread): # ping the office when a new thread is created - if thread.parent == bot.get_channel(private.config[thread.guild.id]["voting_forum_id"]): # in the correct channel - if thread.can_send(): - if thread.parent.get_tag(private.config[thread.guild.id]["debate_tag_id"]) in thread.applied_tags: - embed = discord.Embed(title = "You have submitted your proposal into debate!", description = "You may motion your proposal to vote no sooner than 48 hours after the Flamewarden (or deputy) acknowledges the proposal.") - await thread.send(content=f"<@&{"> <@&".join(map(str, private.config[thread.guild.id]["fw_announcement_role_ids"]))}>", embed=embed) - logger.info("Debate ping sent") - else: - embed = discord.Embed(title = "You have submitted your proposal!", description = "You may motion your proposal to debate at any time by modifying this thread's tags to 'In Debate'.") - await thread.send(embed=embed) - logger.info("Submission embed sent") - -@bot.event -async def on_thread_update(before: discord.Thread, after: discord.Thread): # ping the office when a new thread is created - if after.parent == bot.get_channel(private.config[after.guild.id]["voting_forum_id"]): # in the correct channel - if after.can_send(): - if not before.applied_tags == after.applied_tags: # if a change has actually been made - if after.parent.get_tag(private.config[after.guild.id]["debate_tag_id"]) in after.applied_tags and after.parent.get_tag(private.config[after.guild.id]["debate_tag_id"]) not in before.applied_tags: - embed = discord.Embed(title = "You have submitted your proposal into debate!", description = "You may motion your proposal to vote no sooner than 48 hours after the Flamewarden (or deputy) acknowledges the proposal.") - await after.send(content=f"<@&{"> <@&".join(map(str, private.config[after.guild.id]["fw_announcement_role_ids"]))}>", embed=embed) - logger.info("Debate ping sent") - bot.run(token) diff --git a/src/cogs/halls.py b/src/cogs/halls.py index 0d5a649..c1c4ea5 100644 --- a/src/cogs/halls.py +++ b/src/cogs/halls.py @@ -14,6 +14,73 @@ def __init__(self, bot): halls = discord.SlashCommandGroup("halls", "Commands relating to the Halls of Solaris") office = halls.create_subgroup("office", "Commands pertaining to the Office's management of the Halls") + @office.command( + name="register", + description="Register a proposal to the bot" + ) + @discord.option("name", + description="The name of the proposal going to vote", + type=discord.SlashCommandOptionType.string) + @discord.option("primary_author", + description="The Discord account of the primary author of the proposal", + type=discord.SlashCommandOptionType.user) + @discord.option("link", + description="A link to the text of the proposal", + type=discord.SlashCommandOptionType.string) + @discord.option("type", + description="The type of the proposal", + type=private.ProposalType) + @discord.option("secondary_author_1", + description="The Discord account of a secondary author of the proposal", + required=False, + type=discord.SlashCommandOptionType.user) + @discord.option("secondary_author_2", + description="The Discord account of another secondary author of the proposal", + required=False, + type=discord.SlashCommandOptionType.user) + @discord.option( + "secondary_author_3", + description="The Discord account of a third secondary author of the proposal", + required=False, + type=discord.SlashCommandOptionType.user) + async def register(self, ctx: discord.ApplicationContext, name: str, primary_author: discord.Member, link: str, type: private.ProposalType, secondary_author_1: discord.Member, secondary_author_2: discord.Member, secondary_author_3: discord.Member): + logger.info(f"Register command sent by {ctx.user.id}") + authors = [author for author in [primary_author, secondary_author_1, secondary_author_2, secondary_author_3] if author is not None] + if isinstance(ctx.channel, discord.threads.Thread): + permitted = any(ctx.user.get_role(rid) for rid in map(int, private.config[ctx.guild.id]["fw_permission_role_ids"])) + if permitted: + logger.info("User is authenticated") + if validators.url(link): + # here we will call a cogs.private function that registers the information to the database + + embed = discord.Embed(title = "Proposal acknowledged and registered", description = "The Office of the Flamewarden acknowledges the proposal and has registered it.") + embed.set_footer(text = f"The proposal was acknowledged by {ctx.author.display_name}.") + await ctx.respond(embed = embed) + else: + logger.info("Invalid URL provided: valid URL must be provided") + + embed = discord.Embed(title = "Invalid URL", description = "The link provided is not a valid URL.") + logger.debug("Embed object created") + + await ctx.respond(embed = embed, ephemeral = True) + logger.info("Invalid URL embed sent") + else: + logger.info("User is not authenticated") + + embed = discord.Embed(title = "No Permissions", description = "You do not have the required permissions to run this command.") + logger.debug("Embed object created") + + await ctx.respond(embed = embed, ephemeral = True) + logger.info("No permissions embed sent") + else: + logger.info("Command is not in a thread channel") + + embed = discord.Embed(title = "Wrong Channel Type", description = "Halls commands must be run in a thread.") + logger.debug("Embed object created") + + await ctx.respond(embed = embed, ephemeral = True) + logger.info("Wrong channel type embed sent") + @office.command( name="vote", description="Prepare a vote") diff --git a/src/cogs/private.py b/src/cogs/private.py index a8819f5..4aeefca 100644 --- a/src/cogs/private.py +++ b/src/cogs/private.py @@ -68,6 +68,14 @@ def voting_threshold(self): def is_approvable(self): return (self in ProposalType.approvables) +class ProposalState(Flag): + draft = auto() + debate = auto() + vote = auto() + passed = auto() + failed = auto() + repealed = auto() + async def _format_definite_article(name: str): # format a name to have correct definite article (the) if "the" in name.lower() or name.split(' ')[0].lower() == 'repeal': # if 'the' is in the name the_name = name # the name should be "the [x]"