From eb8456fc201864f42127f053c88ae441fadc0018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C4=A9nh=20Tu=E1=BA=A5n=20Ung?= Date: Mon, 18 Nov 2024 23:44:19 +1030 Subject: [PATCH 1/5] feat(admin): add admin-only commands (#23) --- src/commands/admin_commands.py | 45 ++++++++------------ src/models/admin_settings_db.py | 56 +++++++++++++++++++++++++ src/models/schema/admin_settings_sql.py | 21 ++++++++++ src/utils/settings.py | 10 +++++ 4 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 src/models/admin_settings_db.py create mode 100644 src/models/schema/admin_settings_sql.py create mode 100644 src/utils/settings.py diff --git a/src/commands/admin_commands.py b/src/commands/admin_commands.py index 5faf69d..a10b709 100644 --- a/src/commands/admin_commands.py +++ b/src/commands/admin_commands.py @@ -1,6 +1,7 @@ import os import logging from discord import app_commands, Interaction +from models.admin_settings_db import AdminSettingsDB # Retrieve the list of admin usernames from the .env file ADMIN_USERS = os.getenv("ADMIN_USERS", "").split(",") @@ -9,9 +10,12 @@ class AdminCommands(app_commands.Group): def __init__(self, gemini_bot): super().__init__(name="admin", description="Admin commands for DuckBot setup.") + + # Initialize the database + self.settings_db = AdminSettingsDB() # Add subgroups to the main admin group - self.set = SetSubGroup(self.check_admin) + self.set = SetSubGroup(self.check_admin, self.settings_db) self.reset = ResetSubGroup(self.check_admin, gemini_bot) # Register subgroups @@ -26,36 +30,26 @@ async def check_admin(self, interaction: Interaction) -> bool: logging.info(f"User {user_name} is authorized.") return True else: + await interaction.response.send_message("You don't have permission to execute that command.", ephemeral=True) logging.warning(f"User {user_name} is not authorized.") return False - async def interaction_check(self, interaction: Interaction) -> bool: - """Restrict all admin commands visibility to authorized users only.""" - is_admin = await self.check_admin(interaction) - if is_admin: - return True - await interaction.response.send_message("Unauthorized", ephemeral=True) - return False - @app_commands.command( - name="log-info", description="Display all current environment variables." + name="log-variables", description="Display all current environment variables." ) async def log_info(self, interaction: Interaction): """Command to log and display all relevant environment variables.""" if not await self.check_admin(interaction): - await interaction.response.send_message("Unauthorized", ephemeral=True) return - # Collect environment variable values - guild_id = os.getenv("GUILD_ID", "Not Set") - skullboard_channel_id = os.getenv("SKULLBOARD_CHANNEL_ID", "Not Set") - required_reactions = os.getenv("REQUIRED_REACTIONS", "Not Set") - tenor_api_key = os.getenv("TENOR_API_KEY", "Not Set") - gemini_api_key = os.getenv("GEMINI_API_KEY", "Not Set") + # Get values from database instead of env + guild_id = self.settings_db.get_setting("GUILD_ID") or "Not Set" + skullboard_channel_id = self.settings_db.get_setting("SKULLBOARD_CHANNEL_ID") or "Not Set" + required_reactions = self.settings_db.get_setting("REQUIRED_REACTIONS") or "Not Set" - # Construct a formatted message for environment variables + # Construct message as before config_message = ( - "**Current Environment Variables:**\n" + "**Current Settings:**\n" f"Guild ID: `{guild_id}`\n" f"Skullboard Channel ID: `{skullboard_channel_id}`\n" f"Required Reactions: `{required_reactions}`\n" @@ -65,18 +59,18 @@ async def log_info(self, interaction: Interaction): class SetSubGroup(app_commands.Group): - def __init__(self, check_admin): + def __init__(self, check_admin, settings_db): super().__init__( name="set", description="Set configuration values for DuckBot." ) self.check_admin = check_admin + self.settings_db = settings_db @app_commands.command(name="guild-id", description="Set the guild ID for DuckBot.") async def set_guild_id(self, interaction: Interaction, guild_id: str): if not await self.check_admin(interaction): - await interaction.response.send_message("Unauthorized", ephemeral=True) return - os.environ["GUILD_ID"] = guild_id + self.settings_db.set_setting("GUILD_ID", guild_id) await interaction.response.send_message( f"Guild ID set to {guild_id}.", ephemeral=True ) @@ -88,9 +82,8 @@ async def set_skullboard_channel_id( self, interaction: Interaction, channel_id: str ): if not await self.check_admin(interaction): - await interaction.response.send_message("Unauthorized", ephemeral=True) return - os.environ["SKULLBOARD_CHANNEL_ID"] = channel_id + self.settings_db.set_setting("SKULLBOARD_CHANNEL_ID", channel_id) await interaction.response.send_message( f"Skullboard channel ID set to {channel_id}.", ephemeral=True ) @@ -100,9 +93,8 @@ async def set_skullboard_channel_id( ) async def set_required_reactions(self, interaction: Interaction, reactions: int): if not await self.check_admin(interaction): - await interaction.response.send_message("Unauthorized", ephemeral=True) return - os.environ["REQUIRED_REACTIONS"] = str(reactions) + self.settings_db.set_setting("REQUIRED_REACTIONS", str(reactions)) await interaction.response.send_message( f"Required reactions set to {reactions}.", ephemeral=True ) @@ -117,7 +109,6 @@ def __init__(self, check_admin, gemini_bot): @app_commands.command(name="chat-history", description="Reset Gemini chat history.") async def reset_chat_history(self, interaction: Interaction): if not await self.check_admin(interaction): - await interaction.response.send_message("Unauthorized", ephemeral=True) return # Call the method to reset Gemini chat history diff --git a/src/models/admin_settings_db.py b/src/models/admin_settings_db.py new file mode 100644 index 0000000..2858e88 --- /dev/null +++ b/src/models/admin_settings_db.py @@ -0,0 +1,56 @@ +import os +import sqlite3 +import logging +from pathlib import Path +from .schema.admin_settings_sql import AdminSettingsSQL + +class AdminSettingsDB: + def __init__(self, db_path: str = "db/admin_settings.db"): + # Ensure the data directory exists + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + self.db_path = db_path + self.init_db() + + def get_db_connection(self): + return sqlite3.connect(self.db_path) + + def init_db(self): + """Initialize the database with tables and default values from .env""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + + # Create tables + for statement in AdminSettingsSQL.initialisation_tables: + cursor.execute(statement) + + # Initialize with default values from .env + default_settings = { + "GUILD_ID": os.getenv("GUILD_ID", ""), + "SKULLBOARD_CHANNEL_ID": os.getenv("SKULLBOARD_CHANNEL_ID", ""), + "REQUIRED_REACTIONS": os.getenv("REQUIRED_REACTIONS", "3") + } + + # Only set defaults if the settings don't exist + for key, value in default_settings.items(): + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, value) + ) + + conn.commit() + + def get_setting(self, key: str) -> str: + """Get a setting value from the database""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(AdminSettingsSQL.get_setting, (key,)) + result = cursor.fetchone() + return result[0] if result else None + + def set_setting(self, key: str, value: str): + """Set a setting value in the database""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(AdminSettingsSQL.set_setting, (key, value)) + conn.commit() \ No newline at end of file diff --git a/src/models/schema/admin_settings_sql.py b/src/models/schema/admin_settings_sql.py new file mode 100644 index 0000000..319fe07 --- /dev/null +++ b/src/models/schema/admin_settings_sql.py @@ -0,0 +1,21 @@ +class AdminSettingsSQL: + """Store SQL statements for the admin settings functionalities.""" + + initialisation_tables = [ + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ] + + get_setting = """ + SELECT value FROM settings WHERE key = ?; + """ + + set_setting = """ + INSERT INTO settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + """ \ No newline at end of file diff --git a/src/utils/settings.py b/src/utils/settings.py new file mode 100644 index 0000000..410c2af --- /dev/null +++ b/src/utils/settings.py @@ -0,0 +1,10 @@ +from models.admin_settings_db import AdminSettingsDB +import os + +def get_setting(key: str, default: str = None) -> str: + """Get a setting from the database, falling back to env vars if not found""" + db = AdminSettingsDB() + value = db.get_setting(key) + if value is None: + value = os.getenv(key, default) + return value \ No newline at end of file From 235bf2f4a5140c9944258ca8e859d8e351ca1b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C4=A9nh=20Tu=E1=BA=A5n=20Ung?= Date: Fri, 22 Nov 2024 18:40:37 +1030 Subject: [PATCH 2/5] fix suggestions --- src/commands/admin_commands.py | 24 +++++++++---------- .../{ => databases}/admin_settings_db.py | 7 +++--- src/utils/settings.py | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) rename src/models/{ => databases}/admin_settings_db.py (90%) diff --git a/src/commands/admin_commands.py b/src/commands/admin_commands.py index a10b709..5f98d89 100644 --- a/src/commands/admin_commands.py +++ b/src/commands/admin_commands.py @@ -1,7 +1,7 @@ import os import logging -from discord import app_commands, Interaction -from models.admin_settings_db import AdminSettingsDB +from discord import app_commands, Interaction, Embed +from models.databases.admin_settings_db import AdminSettingsDB # Retrieve the list of admin usernames from the .env file ADMIN_USERS = os.getenv("ADMIN_USERS", "").split(",") @@ -11,7 +11,7 @@ class AdminCommands(app_commands.Group): def __init__(self, gemini_bot): super().__init__(name="admin", description="Admin commands for DuckBot setup.") - # Initialize the database + # Initialise the database self.settings_db = AdminSettingsDB() # Add subgroups to the main admin group @@ -27,11 +27,11 @@ async def check_admin(self, interaction: Interaction) -> bool: logging.info(f"Checking admin status for user: {user_name}") if user_name in ADMIN_USERS: - logging.info(f"User {user_name} is authorized.") + logging.info(f"User {user_name} is authorised.") return True else: await interaction.response.send_message("You don't have permission to execute that command.", ephemeral=True) - logging.warning(f"User {user_name} is not authorized.") + logging.warning(f"User {user_name} is not authorised.") return False @app_commands.command( @@ -47,15 +47,15 @@ async def log_info(self, interaction: Interaction): skullboard_channel_id = self.settings_db.get_setting("SKULLBOARD_CHANNEL_ID") or "Not Set" required_reactions = self.settings_db.get_setting("REQUIRED_REACTIONS") or "Not Set" - # Construct message as before - config_message = ( - "**Current Settings:**\n" - f"Guild ID: `{guild_id}`\n" - f"Skullboard Channel ID: `{skullboard_channel_id}`\n" - f"Required Reactions: `{required_reactions}`\n" + embed = Embed( + title="Current Settings", + color=0x00ff00 ) + embed.add_field(name="Guild ID", value=f"`{guild_id}`", inline=False) + embed.add_field(name="Skullboard Channel ID", value=f"`{skullboard_channel_id}`", inline=False) + embed.add_field(name="Required Reactions", value=f"`{required_reactions}`", inline=False) - await interaction.response.send_message(config_message, ephemeral=True) + await interaction.response.send_message(embed=embed, ephemeral=True) class SetSubGroup(app_commands.Group): diff --git a/src/models/admin_settings_db.py b/src/models/databases/admin_settings_db.py similarity index 90% rename from src/models/admin_settings_db.py rename to src/models/databases/admin_settings_db.py index 2858e88..3247b4a 100644 --- a/src/models/admin_settings_db.py +++ b/src/models/databases/admin_settings_db.py @@ -1,8 +1,7 @@ import os import sqlite3 -import logging from pathlib import Path -from .schema.admin_settings_sql import AdminSettingsSQL +from models.schema.admin_settings_sql import AdminSettingsSQL class AdminSettingsDB: def __init__(self, db_path: str = "db/admin_settings.db"): @@ -16,7 +15,7 @@ def get_db_connection(self): return sqlite3.connect(self.db_path) def init_db(self): - """Initialize the database with tables and default values from .env""" + """Initialise the database with tables and default values from .env""" with self.get_db_connection() as conn: cursor = conn.cursor() @@ -24,7 +23,7 @@ def init_db(self): for statement in AdminSettingsSQL.initialisation_tables: cursor.execute(statement) - # Initialize with default values from .env + # Initialise with default values from .env default_settings = { "GUILD_ID": os.getenv("GUILD_ID", ""), "SKULLBOARD_CHANNEL_ID": os.getenv("SKULLBOARD_CHANNEL_ID", ""), diff --git a/src/utils/settings.py b/src/utils/settings.py index 410c2af..2280ee3 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -1,7 +1,7 @@ from models.admin_settings_db import AdminSettingsDB import os -def get_setting(key: str, default: str = None) -> str: +def get_setting_with_fallback(key: str, default: str = None) -> str: """Get a setting from the database, falling back to env vars if not found""" db = AdminSettingsDB() value = db.get_setting(key) From faa4a92f7733b043eeca7d52d7eea5950b2c81bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C4=A9nh=20Tu=E1=BA=A5n=20Ung?= Date: Mon, 25 Nov 2024 22:35:44 +1030 Subject: [PATCH 3/5] add new env var to production.yml --- .github/workflows/production.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 42cbc36..2b6e7f2 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -84,6 +84,7 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} REQUESTS_PER_MINUTE: ${{ secrets.REQUESTS_PER_MINUTE }} LIMIT_WINDOW: ${{ secrets.LIMIT_WINDOW }} + ADMIN_USERS: ${{ secrets.ADMIN_USERS }} run: | echo "$KEY" > private_key && chmod 600 private_key ssh -v -o StrictHostKeyChecking=no -i private_key ${USER}@${HOSTNAME} ' From 737c6373ae8fb7224e59eaa1f45e182593824cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C4=A9nh=20Tu=E1=BA=A5n=20Ung?= Date: Wed, 27 Nov 2024 15:37:29 +1030 Subject: [PATCH 4/5] add echo admin env --- .github/workflows/production.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 2b6e7f2..084e2af 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -99,6 +99,7 @@ jobs: echo GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} >> .env echo REQUESTS_PER_MINUTE=${{ secrets.REQUESTS_PER_MINUTE }} >> .env echo LIMIT_WINDOW=${{ secrets.LIMIT_WINDOW }} >> .env + echo ADMIN_USERS=${{ secrets.ADMIN_USERS }} >> .env docker load -i duckbot.tar.gz docker compose up -d ' From 98eeeb574e685e892b5a0fb6a6168919d93aa0e1 Mon Sep 17 00:00:00 2001 From: Phoenix Isaac Pereira Date: Wed, 27 Nov 2024 19:10:01 +1030 Subject: [PATCH 5/5] chore: Run Black --- src/commands/admin_commands.py | 29 +++++++++++++++-------- src/models/databases/admin_settings_db.py | 13 +++++----- src/models/schema/admin_settings_sql.py | 2 +- src/utils/settings.py | 3 ++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/commands/admin_commands.py b/src/commands/admin_commands.py index 5f98d89..60b37f2 100644 --- a/src/commands/admin_commands.py +++ b/src/commands/admin_commands.py @@ -10,7 +10,7 @@ class AdminCommands(app_commands.Group): def __init__(self, gemini_bot): super().__init__(name="admin", description="Admin commands for DuckBot setup.") - + # Initialise the database self.settings_db = AdminSettingsDB() @@ -30,7 +30,9 @@ async def check_admin(self, interaction: Interaction) -> bool: logging.info(f"User {user_name} is authorised.") return True else: - await interaction.response.send_message("You don't have permission to execute that command.", ephemeral=True) + await interaction.response.send_message( + "You don't have permission to execute that command.", ephemeral=True + ) logging.warning(f"User {user_name} is not authorised.") return False @@ -44,16 +46,23 @@ async def log_info(self, interaction: Interaction): # Get values from database instead of env guild_id = self.settings_db.get_setting("GUILD_ID") or "Not Set" - skullboard_channel_id = self.settings_db.get_setting("SKULLBOARD_CHANNEL_ID") or "Not Set" - required_reactions = self.settings_db.get_setting("REQUIRED_REACTIONS") or "Not Set" - - embed = Embed( - title="Current Settings", - color=0x00ff00 + skullboard_channel_id = ( + self.settings_db.get_setting("SKULLBOARD_CHANNEL_ID") or "Not Set" + ) + required_reactions = ( + self.settings_db.get_setting("REQUIRED_REACTIONS") or "Not Set" ) + + embed = Embed(title="Current Settings", color=0x00FF00) embed.add_field(name="Guild ID", value=f"`{guild_id}`", inline=False) - embed.add_field(name="Skullboard Channel ID", value=f"`{skullboard_channel_id}`", inline=False) - embed.add_field(name="Required Reactions", value=f"`{required_reactions}`", inline=False) + embed.add_field( + name="Skullboard Channel ID", + value=f"`{skullboard_channel_id}`", + inline=False, + ) + embed.add_field( + name="Required Reactions", value=f"`{required_reactions}`", inline=False + ) await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/src/models/databases/admin_settings_db.py b/src/models/databases/admin_settings_db.py index 3247b4a..0bed8e8 100644 --- a/src/models/databases/admin_settings_db.py +++ b/src/models/databases/admin_settings_db.py @@ -3,11 +3,12 @@ from pathlib import Path from models.schema.admin_settings_sql import AdminSettingsSQL + class AdminSettingsDB: def __init__(self, db_path: str = "db/admin_settings.db"): # Ensure the data directory exists Path(db_path).parent.mkdir(parents=True, exist_ok=True) - + self.db_path = db_path self.init_db() @@ -18,7 +19,7 @@ def init_db(self): """Initialise the database with tables and default values from .env""" with self.get_db_connection() as conn: cursor = conn.cursor() - + # Create tables for statement in AdminSettingsSQL.initialisation_tables: cursor.execute(statement) @@ -27,16 +28,16 @@ def init_db(self): default_settings = { "GUILD_ID": os.getenv("GUILD_ID", ""), "SKULLBOARD_CHANNEL_ID": os.getenv("SKULLBOARD_CHANNEL_ID", ""), - "REQUIRED_REACTIONS": os.getenv("REQUIRED_REACTIONS", "3") + "REQUIRED_REACTIONS": os.getenv("REQUIRED_REACTIONS", "3"), } # Only set defaults if the settings don't exist for key, value in default_settings.items(): cursor.execute( "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", - (key, value) + (key, value), ) - + conn.commit() def get_setting(self, key: str) -> str: @@ -52,4 +53,4 @@ def set_setting(self, key: str, value: str): with self.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(AdminSettingsSQL.set_setting, (key, value)) - conn.commit() \ No newline at end of file + conn.commit() diff --git a/src/models/schema/admin_settings_sql.py b/src/models/schema/admin_settings_sql.py index 319fe07..873bfac 100644 --- a/src/models/schema/admin_settings_sql.py +++ b/src/models/schema/admin_settings_sql.py @@ -18,4 +18,4 @@ class AdminSettingsSQL: INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value; - """ \ No newline at end of file + """ diff --git a/src/utils/settings.py b/src/utils/settings.py index 2280ee3..d07ca57 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -1,10 +1,11 @@ from models.admin_settings_db import AdminSettingsDB import os + def get_setting_with_fallback(key: str, default: str = None) -> str: """Get a setting from the database, falling back to env vars if not found""" db = AdminSettingsDB() value = db.get_setting(key) if value is None: value = os.getenv(key, default) - return value \ No newline at end of file + return value