Skip to content
Draft
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
36 changes: 36 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions sql/00-tables.sh
Original file line number Diff line number Diff line change
@@ -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
184 changes: 137 additions & 47 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
67 changes: 67 additions & 0 deletions src/cogs/halls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions src/cogs/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down