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):