diff --git a/Dockerfile b/Dockerfile index 88ed601..0c634fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # begin with the base Alpine python image -FROM python:3.14.6-alpine3.23 +FROM python:3.14.5-alpine3.23 # create a directory to store the application WORKDIR /usr/local/ns-assembly diff --git a/conf/app/assembly.yaml b/conf/app/assembly.yaml new file mode 100644 index 0000000..e69de29 diff --git a/src/__main__.py b/old_src/__main__.py similarity index 97% rename from src/__main__.py rename to old_src/__main__.py index 78a13da..af6f862 100644 --- a/src/__main__.py +++ b/old_src/__main__.py @@ -223,23 +223,25 @@ async def _create_thread_ifv_for_proposal(proposal:classes.wa.Proposal) -> None: # define a method of fetching proposals async def _fetch_proposals() -> None: - """Fetch World Assembly proposals from the NS API, for both councils, into the database.""" + """Return World Assembly proposals from the NS API, for both councils, and create threads""" for council in [1,2]: # for each council logger.debug('New council') proposals = await ns_api.parse_proposals(council) # load a parsed version of the proposals from the API logger.info('Proposal data fetched from API') + legal_proposals = [] + for proposal in proposals: # for each proposal if proposal.legal and proposal.quorum: logger.debug('Proposal legal and at quorum') - await ns_postgres.nsqueue_add(proposal) # add it to the NSQueue table - logger.info('Proposal added to NSQueue') - await _create_thread_ifv_for_proposal(proposal) # create a thread and add it to the IFVQueue table logger.info('Proposal thread being created') - logger.info('Proposal data parsed and stored') + + legal_proposals.append(proposal) + logger.info('Proposal data parsed') + return legal_proposals async def _new_sse_event(payload:str) -> None: logger.debug('New SSE event received, checking proposals...') @@ -271,7 +273,7 @@ async def _check_perms(ctx:discord.ApplicationContext, check_kind:str) -> bool: async def _get_queue_embed(council:int) -> discord.Embed: """Get an embed with the World Assembly proposal queue included.""" - queue = await ns_postgres.nsqueue_get_all_legal_by_council_limited(council = council) # fetch all proposals in the NSQueue table + queue = await _fetch_proposals() # fetch all proposals in the NSQueue table logger.debug('Proposals fetched from DB') table = 'Stance | Name | Status | Proposal Link | IFV Author | IFV Link\n' # create a table, starting with the header @@ -364,7 +366,8 @@ async def _announce_queue(ctx: discord.ApplicationContext, council:int, ping_use logger.info('Queue embed fetched') if ping_users: - ping = await ns_postgres.botperms_get_by_kind('user').identifier + ping_object = await ns_postgres.botperms_get_by_kind('user') + ping = ping_object.identifier logger.debug('Ping role id found') await ctx.respond(f'<@&{ping}>', embed = embed, ephemeral = False, allowed_mentions = discord.AllowedMentions(roles = True), view=IFVView(council = council)) @@ -489,7 +492,7 @@ async def announce_sc_queue(ctx: discord.ApplicationContext,ping_users:bool) -> @bot.event async def on_application_command_error(ctx:discord.ApplicationContext, error:discord.DiscordException): logger.error(error) - ctx.respond('<@1271403487045095465> An unspecified error occurred.') + await ctx.respond('<@1271403487045095465> An unspecified error occurred.') async def main() -> None: try: diff --git a/src/classes/__init__.py b/old_src/classes/__init__.py similarity index 100% rename from src/classes/__init__.py rename to old_src/classes/__init__.py diff --git a/src/classes/auth.py b/old_src/classes/auth.py similarity index 97% rename from src/classes/auth.py rename to old_src/classes/auth.py index 37a934c..f839018 100644 --- a/src/classes/auth.py +++ b/old_src/classes/auth.py @@ -26,10 +26,12 @@ def fromAttributeValues(self, kind:str, identifier:int): self.kind = kind self.identifier = identifier self.initialized = True + return self def fromSQLValues(self, values:tuple[str, int]): self.kind = values[0] self.identifier = values[1] self.initialized = True + return self def toSQLValues(self) -> tuple[str, int]: if self.initialized: return (self.kind, self.identifier) diff --git a/src/classes/exceptions/__init__.py b/old_src/classes/exceptions/__init__.py similarity index 100% rename from src/classes/exceptions/__init__.py rename to old_src/classes/exceptions/__init__.py diff --git a/src/classes/ifv.py b/old_src/classes/ifv.py similarity index 97% rename from src/classes/ifv.py rename to old_src/classes/ifv.py index 7555da1..e889369 100644 --- a/src/classes/ifv.py +++ b/old_src/classes/ifv.py @@ -28,6 +28,7 @@ def fromAttributeValues(self, id:str, name:str, thread:str | None = None, ifvaut self.ifvauthor = ifvauthor self.ifvlink = ifvlink self.initialized = True + return self def fromSQLValues(self, values:tuple[str, str, str | None, str | None, str | None]): self.id = values[0] self.name = values[1] @@ -35,6 +36,7 @@ def fromSQLValues(self, values:tuple[str, str, str | None, str | None, str | Non self.ifvauthor = values[3] self.ifvlink = values[4] self.initialized = True + return self def toSQLValues(self) -> tuple[str, str, str, str | None, str | None, str | None]: if self.initialized: return (self.id,self.name,self.thread,self.ifvauthor,self.ifvlink) diff --git a/src/classes/sse.py b/old_src/classes/sse.py similarity index 98% rename from src/classes/sse.py rename to old_src/classes/sse.py index ea00cfe..13a5165 100644 --- a/src/classes/sse.py +++ b/old_src/classes/sse.py @@ -29,6 +29,7 @@ def fromAttributeValues(self, event:int, time:int, category:str, data:list, acto self.category = category self.data = data self.initialized = True + return self def fromSQLValues(self, values:tuple[int, int, str, list, str | None, str | None, str | None, str | None]): self.event = values[0] self.time = values[1] @@ -39,6 +40,7 @@ def fromSQLValues(self, values:tuple[int, int, str, list, str | None, str | None self.category = values[6] self.data = values[7] self.initialized = True + return self def toSQLValues(self) -> tuple[int, int, str, list, str | None, str | None, str | None, str | None]: if self.initialized: return (self.event,self.time,self.actor,self.receptor,self.origin,self.destination,self.category,self.data) diff --git a/src/classes/wa.py b/old_src/classes/wa.py similarity index 97% rename from src/classes/wa.py rename to old_src/classes/wa.py index 1df2c9a..19f4ec8 100644 --- a/src/classes/wa.py +++ b/old_src/classes/wa.py @@ -31,6 +31,7 @@ def fromAttributeValues(self, id:str, council:int, name:str, category:str, autho self.legal = legal self.quorum = quorum self.initialized = True + return self def fromSQLValues(self, values:tuple[str, int, str, str, str, bool, bool, list[str | None]]): self.id = values[0] self.council = values[1] @@ -41,6 +42,7 @@ def fromSQLValues(self, values:tuple[str, int, str, str, str, bool, bool, list[s self.legal = values[6] self.quorum = values[7] self.initialized = True + return self def toSQLValues(self) -> tuple[str, int, str, str, str, bool, bool, list[str | None]]: if self.initialized: return (self.id,self.council,self.name,self.category,self.author,self.coauthors,self.legal,self.quorum) diff --git a/src/customio/__init__.py b/old_src/customio/__init__.py similarity index 95% rename from src/customio/__init__.py rename to old_src/customio/__init__.py index ce24716..60eabb2 100644 --- a/src/customio/__init__.py +++ b/old_src/customio/__init__.py @@ -17,4 +17,5 @@ # simple program to combine other modules into the 'customio' library from .db import * from .env import * -from .ns import * \ No newline at end of file +from .ns import * +from .conf import * \ No newline at end of file diff --git a/old_src/customio/conf.py b/old_src/customio/conf.py new file mode 100644 index 0000000..34249cd --- /dev/null +++ b/old_src/customio/conf.py @@ -0,0 +1,27 @@ +'''This file is part of assembly. +Copyright (C) 2026 HippoProgrammer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .''' + +import yaml +import os +from .exceptions import * + +def load_config_from_file(path:str): + if os.path.isfile(path): + with open(path, 'r') as file: + config = yaml.safe_load(file) + return config + else: + raise exceptions.InvalidPathException('Config path is not a valid file. Cannot start') \ No newline at end of file diff --git a/src/customio/db.py b/old_src/customio/db.py similarity index 82% rename from src/customio/db.py rename to old_src/customio/db.py index 5bff1bf..099d974 100644 --- a/src/customio/db.py +++ b/old_src/customio/db.py @@ -113,67 +113,6 @@ async def setup_all(self) -> None: await self._open_connection_pool() async def cleanup(self) -> None: await self._close_connection_pool() - # NSQueue table - async def nsqueue_add(self,proposal:classes.wa.Proposal) -> None: - """Add a Proposal to the NSQueue""" - try: # protect against PoolTimeouts - async with self.connection_pool.connection() as conn: # get a connection from the pool - self.logger.debug('DB connection opened from pool') - async with conn.cursor() as cur: # open a cursor - self.logger.debug('Cursor opened') - - await cur.execute(""" - INSERT INTO NSQueue (ID, Council, Name, Category, Author, Coauthors, Legal, Quorum) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (ID) DO NOTHING; - """,proposal.toSQLValues()) # insert data into the table, but if it already exists ignore it - self.logger.info('Successful query') - - await conn.commit() - except psycopg_pool.PoolTimeout: - self.connection_self.connection_pool.check() - async def nsqueue_get_by_id(self, id:str) -> classes.wa.Proposal: - """Get a proposal by ID from the NSQueue""" - try: - async with self.connection_pool.connection() as conn: # get a connection from the pool - self.logger.debug('DB connection opened from pool') - async with conn.cursor() as cur: # open a cursor - self.logger.debug('Cursor opened') - - await cur.execute(""" - SELECT * FROM NSQueue - WHERE ID = %s; - """, [id]) # select all proposals with the supplied ID - self.logger.info('Successful query') - - SQLproposal = await cur.fetchone() # fetch the proposal (as ID is unique there is only one) - proposal = classes.wa.Proposal().fromSQLValues(SQLproposal) # convert it into a Proposal object - return proposal - except psycopg_pool.PoolTimeout: - self.connection_self.connection_pool.check() - async def nsqueue_get_all_legal_by_council_limited(self, council:int = 1, limit:int = 7) -> list[classes.wa.Proposal]: - """Get all proposals that are legal from the NSQueue, up to the specified limit""" - try: - async with self.connection_pool.connection() as conn: # get a connection from the pool - self.logger.debug('DB connection opened from pool') - async with conn.cursor() as cur: # open a cursor - self.logger.debug('Cursor opened') - - await cur.execute(""" - SELECT * FROM NSQueue - WHERE Legal AND Council = %s - LIMIT %s; - """, [council, limit]) # select all legal proposals, up to the queue limit of seven - self.logger.info('Successful query') - - SQLqueue = await cur.fetchall() # fetch them all - queue = [] - for item in SQLqueue: - queue.append(classes.wa.Proposal().fromSQLValues(item)) # convert them into a list of Proposal objects - return queue - - except psycopg_pool.PoolTimeout: - self.connection_self.connection_pool.check() # IFVQueue table async def ifvqueue_add(self, ifv:classes.ifv.IFV) -> None: """Add an IFV to the IFVQueue""" diff --git a/src/customio/env.py b/old_src/customio/env.py similarity index 86% rename from src/customio/env.py rename to old_src/customio/env.py index 222cbac..af9b47b 100644 --- a/src/customio/env.py +++ b/old_src/customio/env.py @@ -16,6 +16,7 @@ import os import logging +from .exceptions import * # set up a logger logger = logging.getLogger('assembly.customio.env') # get the logger for this script @@ -27,13 +28,9 @@ def load_secrets_from_envvars() -> tuple[str, str]: # sanity-check envvars if not os.path.isfile(token_file): - msg = 'ASSEMBLY_TOKEN_FILE environment variable is not a valid path, cannot start' - logger.error(msg) - raise Exception(msg) + raise exceptions.InvalidPathException('ASSEMBLY_TOKEN_FILE environment variable is not a valid path, cannot start') if not os.path.isfile(pgpass_file): - msg = 'POSTGRES_PASS_FILE environment variable is not a valid path, cannot start' - logger.error(msg) - raise Exception(msg) + raise exceptions.InvalidPathException('POSTGRES_PASS_FILE environment variable is not a valid path, cannot start') # read token file with open(token_file,'r') as file: diff --git a/old_src/customio/exceptions/__init__.py b/old_src/customio/exceptions/__init__.py new file mode 100644 index 0000000..89fd59c --- /dev/null +++ b/old_src/customio/exceptions/__init__.py @@ -0,0 +1,19 @@ +'''This file is part of assembly. +Copyright (C) 2026 HippoProgrammer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .''' + +# renamed Exceptions for use by custom libraries +class InvalidPathException(Exception): + pass \ No newline at end of file diff --git a/src/customio/ns.py b/old_src/customio/ns.py similarity index 100% rename from src/customio/ns.py rename to old_src/customio/ns.py diff --git a/requirements.txt b/requirements.txt index 2dcf1fb..f90260a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ py-cord[speed]==2.8.0 # Discord API wrapper psycopg[binary,pool]==3.3.4 # Postgres connector aiohttp[speedups]==3.14.1 # Async HTTP requests -validators==0.35.0 # String sanitization and validation \ No newline at end of file +validators==0.35.0 # String sanitization and validation +aiohttp[speedups]==3.13.5 # Async HTTP requests +validators==0.35.0 # String sanitization and validation +PyYAML==6.0.3 # YAML parser diff --git a/sql/init/04-apptables.sh b/sql/init/04-apptables.sh index 67109c4..d1a48a5 100644 --- a/sql/init/04-apptables.sh +++ b/sql/init/04-apptables.sh @@ -20,17 +20,6 @@ along with this program. If not, see . psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL \c ns_assembly - CREATE TABLE IF NOT EXISTS NSQueue ( - ID TEXT PRIMARY KEY, - Council SMALLINT CHECK (Council = 1 OR Council = 2), - Name TEXT NOT NULL, - Category TEXT NOT NULL, - Author TEXT NOT NULL, - Coauthors TEXT ARRAY[3], - Legal BOOL NOT NULL, - Quorum BOOL NOT NULL - ); -- create a table for storing queued proposal information, direct from the NS API - CREATE TABLE IF NOT EXISTS IFVQueue ( ID TEXT PRIMARY KEY, Name TEXT NOT NULL, diff --git a/sql/init/05-appindexes.sh b/sql/init/05-appindexes.sh index 77d1eea..afc4603 100644 --- a/sql/init/05-appindexes.sh +++ b/sql/init/05-appindexes.sh @@ -20,6 +20,5 @@ along with this program. If not, see . psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL \c ns_assembly - CREATE INDEX IF NOT EXISTS NSQueue_ID_index ON NSQueue (ID); -- create relevant indexes on frequently-queried tables CREATE INDEX IF NOT EXISTS IFVQueue_Author_index on IFVQueue (IFVAuthor); EOSQL \ No newline at end of file diff --git a/sql/init/06-akarischema.sh b/sql/init/06-akarischema.sh index 91c5aad..95b836d 100644 --- a/sql/init/06-akarischema.sh +++ b/sql/init/06-akarischema.sh @@ -27,6 +27,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E ALTER ROLE ns_akari IN DATABASE ns_akari SET search_path TO akari; GRANT ALL PRIVILEGES ON SCHEMA akari TO ns_akari; + GRANT ALL PRIVILEGES ON SCHEMA public TO ns_akari; GRANT ALL PRIVILEGES ON SCHEMA akari TO ns_assembly_app; ALTER DEFAULT PRIVILEGES FOR ROLE ns_akari IN SCHEMA akari GRANT ALL ON TABLES TO ns_akari; ALTER DEFAULT PRIVILEGES FOR ROLE ns_akari IN SCHEMA akari GRANT SELECT, TRIGGER ON TABLES TO ns_assembly_app; diff --git a/src/classes.py b/src/classes.py new file mode 100644 index 0000000..6dd4540 --- /dev/null +++ b/src/classes.py @@ -0,0 +1,39 @@ +from copy import deepcopy + +class DataStruct: + def __init__(self, structure: dict) -> None: + self.structure = structure + self.data = deepcopy(self.structure) + def _from_values(self, values: dict): + for key in (set(self.structure) & set(values)): + self.structure[key] = values[key] + def from_attribute_values(self, **kwargs): + self._from_values(kwargs) + def from_sql_values(self, values: dict): + for key in (set(self.structure) & set(values)): +# fix to match above + +class IFV: + def __init__(self): + self.initialized = False + def fromAttributeValues(self, id:str, name:str, thread:str | None = None, ifvauthor:str | None = None, ifvlink:str | None = None): + self.id = id + self.name = name + self.thread = thread + self.ifvauthor = ifvauthor + self.ifvlink = ifvlink + self.initialized = True + return self + def fromSQLValues(self, values:tuple[str, str, str | None, str | None, str | None]): + self.id = values[0] + self.name = values[1] + self.thread = values[2] + self.ifvauthor = values[3] + self.ifvlink = values[4] + self.initialized = True + return self + def toSQLValues(self) -> tuple[str, str, str, str | None, str | None, str | None]: + if self.initialized: + return (self.id,self.name,self.thread,self.ifvauthor,self.ifvlink) + else: + raise exceptions.UninitializedException() \ No newline at end of file diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..5355dd3 --- /dev/null +++ b/src/db.py @@ -0,0 +1,66 @@ +import asyncio +import psycopg_pool +import psycopg.errors +import logging +import typing +import classes + +class DBResponse: + def __init__(self, success: bool, response: list | None): + self.success = success + self.response = response + def __bool__(self) -> bool: + return self.success + def get_response(self) -> list | None: + return self.response + +class Database: + def __init__(self, connection_uri: str) -> 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.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') + 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) -> DBResponse: + "Send raw SQL queries to the database" + try: + async with self.connection_pool.connection() as conn: # get a connection from the pool + 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(cur.fetchall()) + + self.logger.debug('Results fetched successfully') + + success = True + except psycopg_pool.PoolTimeout as e: + self.logger.warning(e) + self.connection_pool.check() + success = False + except psycopg.errors.Error as e: + self.logger.error(e) + success = False + + response = DBResponse( + success = success, + response = results + ) + + return response + + + +class NSAssembly(Database): + async def ifvqueue_add(self, ifv:classes.IFV):