diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..648710e --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,60 @@ +name: Create and Publish Docker Image CI + + +on: + push: + branches: [ "main" ] + tags: [ "v*.*.*" ] + pull_request: + +jobs: + + docker: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + wlinator/luminara + ghcr.io/wlinator/luminara + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v6 + with: + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + + diff --git a/.gitignore b/.gitignore index 7a6ed10..4747dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ venv/ __pycache__/ .run/ .vscode/ +data/ *.db .env diff --git a/Client.py b/Client.py index 9e7ad60..e098926 100644 --- a/Client.py +++ b/Client.py @@ -7,7 +7,7 @@ from discord.ext import bridge, commands from discord.ext.commands import EmojiConverter, TextChannelConverter from loguru import logger -from lib import metadata +from lib.constants import CONST class LumiBot(bridge.Bot): @@ -18,7 +18,7 @@ class LumiBot(bridge.Bot): Logs various information about the bot and the environment it is running on. Note: This function isn't guaranteed to only be called once. The event is called when a RESUME request fails. """ - logger.info(f"{metadata.__title__} v{metadata.__version__}") + logger.info(f"{CONST.TITLE} v{CONST.VERSION}") logger.info(f"Logged in with ID {self.user.id if self.user else 'Unknown'}") logger.info(f"discord.py API version: {discord.__version__}") logger.info(f"Python version: {platform.python_version()}") diff --git a/Dockerfile b/Dockerfile index 76e47fa..801a53b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,24 @@ -FROM python:3.12 +FROM python:3.12-slim-bookworm ARG DEBIAN_FRONTEND=noninteractive WORKDIR /usr/src/app RUN apt-get update && \ - apt-get install -y locales mariadb-client && \ + apt-get install -y --no-install-recommends locales mariadb-client && \ sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ - dpkg-reconfigure locales + dpkg-reconfigure locales && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* COPY pyproject.toml poetry.lock ./ -RUN pip install poetry && \ +RUN pip install --no-cache-dir poetry && \ poetry config virtualenvs.create false && \ - poetry install --no-interaction --no-ansi + poetry install --no-interaction --no-ansi --no-dev && \ + pip cache purge COPY . . -ENV LANG en_US.UTF-8 -ENV LC_ALL en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LC_ALL=en_US.UTF-8 CMD [ "poetry", "run", "python", "./Luminara.py" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE index e01b934..99d60b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,7 @@ -Luminara -Copyright (C) 2022-2024 wlinator - -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 -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 . - - GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. @@ -648,8 +631,8 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - Copyright (C) + Luminara - A Discord bot application + Copyright (C) 2022-present wlinator (dokimakimaki@gmail.com) 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 @@ -662,14 +645,14 @@ the "copyright" line and a pointer to where the full notice is found. 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 . + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - Luminara Copyright (C) 2022-2024 wlinator + Luminara Copyright (C) 2022-present wlinator (dokimakimaki@gmail.com) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. @@ -681,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see -. +. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. \ No newline at end of file +. diff --git a/Luminara.py b/Luminara.py index 364514e..88e1309 100644 --- a/Luminara.py +++ b/Luminara.py @@ -10,6 +10,7 @@ import services.config_service import services.help_service from lib.constants import CONST from services.blacklist_service import BlacklistUserService +from db.database import run_migrations # Remove the default logger configuration logger.remove() @@ -42,9 +43,7 @@ client = Client.LumiBot( @client.check async def blacklist_check(ctx): - if BlacklistUserService.is_user_blacklisted(ctx.author.id): - return False - return True + return not BlacklistUserService.is_user_blacklisted(ctx.author.id) def load_modules(): @@ -87,6 +86,9 @@ if __name__ == "__main__": logger.info("LUMI IS BOOTING") + # Run database migrations + run_migrations() + # cache all JSON [ config.parser.JsonCache.read_json(file[:-5]) diff --git a/README.md b/README.md index 38c04c8..c830522 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,43 @@ ![Lumi art](https://git.wlinator.org/assets/img/logo.png) + +## Self-Hosting + +Self-hosting refers to running Luminara on your own server or computer, rather than using the publicly hosted version. This approach offers the ability to manage your own instance of the bot and give it a custom name and avatar. + +### Requirements + +Before you begin, make sure you have the following installed on your system: +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) + +Additionally, you'll need to create a Discord bot application and obtain a token: + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications). +2. Click on "New Application" and give it a name. +3. Navigate to the "Bot" tab and click "Add Bot". +4. Under the bot's username, click "Reset Token" to reveal your bot token. +5. Copy this token; you'll need it for the `.env` file later. + +*Note: remember to keep your bot token secret and never share it publicly.* + +### Running Luminara: + +1. Copy the contents from [docker-compose.prod.yml](docker-compose.prod.yml) to a new file named `docker-compose.yml` in an empty directory. + +2. Copy the `.env.example` file to a new file named `.env` in the same directory. + +3. Fill out the `.env` file with your specific configuration details. + +4. Run the following command in your terminal: + + ``` + docker compose up -d --build + ``` + +This will build and start Luminara in detached mode. + --- Some icons used in Lumi are provided by [Icons8](https://icons8.com/). diff --git a/config/parser.py b/config/parser.py index 324a13a..5e4b35c 100644 --- a/config/parser.py +++ b/config/parser.py @@ -1,8 +1,5 @@ import json -import yaml -from loguru import logger - class JsonCache: _cache = {} @@ -13,21 +10,5 @@ class JsonCache: if path not in JsonCache._cache: with open(f"config/JSON/{path}.json") as file: JsonCache._cache[path] = json.load(file) - logger.debug(f"{path}.json was loaded and cached.") return JsonCache._cache[path] - - -class YamlCache: - _cache = {} - - @staticmethod - def read_credentialsl(): - """Read and cache the creds.yaml data if not already cached.""" - path = "creds" - if path not in YamlCache._cache: - with open(f"{path}.yaml") as file: - YamlCache._cache[path] = yaml.safe_load(file) - logger.debug(f"{path}.yaml was loaded and cached.") - - return YamlCache._cache[path] diff --git a/db/database.py b/db/database.py index c7c6c04..c63b752 100644 --- a/db/database.py +++ b/db/database.py @@ -1,6 +1,9 @@ import mysql.connector from loguru import logger from mysql.connector import pooling +import os +import pathlib +import re from lib.constants import CONST @@ -54,3 +57,57 @@ def select_query_dict(query, values=None): with conn.cursor(dictionary=True) as cursor: cursor.execute(query, values) return cursor.fetchall() + +def run_migrations(): + migrations_dir = "db/migrations" + migration_files = sorted( + [f for f in os.listdir(migrations_dir) if f.endswith(".sql")], + ) + + with _cnxpool.get_connection() as conn: + with conn.cursor() as cursor: + # Create migrations table if it doesn't exist + cursor.execute(""" + CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + for migration_file in migration_files: + # Check if migration has already been applied + cursor.execute( + "SELECT COUNT(*) FROM migrations WHERE filename = %s", + (migration_file,), + ) + if cursor.fetchone()[0] > 0: + logger.debug( + f"Migration {migration_file} already applied, skipping.", + ) + continue + + # Read and execute migration file + migration_sql = pathlib.Path( + os.path.join(migrations_dir, migration_file), + ).read_text() + try: + # Split the migration file into individual statements + statements = re.split(r";\s*$", migration_sql, flags=re.MULTILINE) + for statement in statements: + if statement.strip(): + cursor.execute(statement) + + # Record successful migration + cursor.execute( + "INSERT INTO migrations (filename) VALUES (%s)", + (migration_file,), + ) + conn.commit() + logger.debug(f"Successfully applied migration: {migration_file}") + except mysql.connector.Error as e: + conn.rollback() + logger.error(f"Error applying migration {migration_file}: {e}") + raise + + logger.debug("All migrations completed.") diff --git a/docker-compose.yml b/docker-compose.dev.yml similarity index 84% rename from docker-compose.yml rename to docker-compose.dev.yml index f5c609c..11a3706 100644 --- a/docker-compose.yml +++ b/docker-compose.dev.yml @@ -3,6 +3,9 @@ services: build: . container_name: lumi-core restart: always + env_file: + - path: ./.env + required: true depends_on: db: condition: service_healthy @@ -17,8 +20,7 @@ services: MARIADB_PASSWORD: ${MARIADB_PASSWORD} MARIADB_DATABASE: ${MARIADB_DATABASE} volumes: - - ./db/migrations:/docker-entrypoint-initdb.d/ - - database:/var/lib/mysql/ + - ./data:/var/lib/mysql/ healthcheck: test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ] interval: 5s @@ -30,7 +32,4 @@ services: container_name: lumi-adminer restart: always ports: - - 8080:8080 - -volumes: - database: + - 8080:8080 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c24272d --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,28 @@ +services: + core: + image: ghcr.io/wlinator/luminara:2 # Remove "ghcr.io/" if you want to use the Docker Hub image. + container_name: lumi-core + restart: always + env_file: + - path: ./.env + required: true + depends_on: + db: + condition: service_healthy + + db: + image: mariadb + container_name: lumi-db + restart: always + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} + MARIADB_USER: ${MARIADB_USER} + MARIADB_PASSWORD: ${MARIADB_PASSWORD} + MARIADB_DATABASE: ${MARIADB_DATABASE} + volumes: + - ./data:/var/lib/mysql/ + healthcheck: + test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ] + interval: 5s + timeout: 10s + retries: 5 diff --git a/lib/constants.py b/lib/constants.py index 9fd031a..b37c4b0 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -1,18 +1,19 @@ import os from typing import Optional, Set -from dotenv import load_dotenv - from config.parser import JsonCache -# Load environment variables from .env file -load_dotenv() - art = JsonCache.read_json("art") resources = JsonCache.read_json("resources") class Constants: + # metadata + TITLE = "Luminara" + AUTHOR = "wlinator" + LICENSE = "GNU General Public License v3.0" + VERSION = "2.7.0" + # bot credentials TOKEN: Optional[str] = os.environ.get("TOKEN", None) INSTANCE: Optional[str] = os.environ.get("INSTANCE", None) @@ -39,9 +40,9 @@ class Constants: EMOTES_GUILD_ID = 1038051105642401812 # color scheme - COLOR_DEFAULT = int(0xFF8C00) - COLOR_WARNING = int(0xFF7600) - COLOR_ERROR = int(0xFF4500) + COLOR_DEFAULT = 0xFF8C00 + COLOR_WARNING = 0xFF7600 + COLOR_ERROR = 0xFF4500 # strings STRINGS = JsonCache.read_json("strings") @@ -84,3 +85,6 @@ class Constants: CONST = Constants() + + +CONST = Constants() diff --git a/lib/interaction.py b/lib/interaction.py index 581fb6d..d71134a 100644 --- a/lib/interaction.py +++ b/lib/interaction.py @@ -36,14 +36,13 @@ class BlackJackButtons(View): self.stop() async def interaction_check(self, interaction) -> bool: - if interaction.user != self.ctx.author: - await interaction.response.send_message( - "You can't use these buttons, they're someone else's!", - ephemeral=True, - ) - return False - else: + if interaction.user == self.ctx.author: return True + await interaction.response.send_message( + "You can't use these buttons, they're someone else's!", + ephemeral=True, + ) + return False class ExchangeConfirmation(View): @@ -69,11 +68,10 @@ class ExchangeConfirmation(View): self.stop() async def interaction_check(self, interaction) -> bool: - if interaction.user != self.ctx.author: - await interaction.response.send_message( - "You can't use these buttons, they're someone else's!", - ephemeral=True, - ) - return False - else: + if interaction.user == self.ctx.author: return True + await interaction.response.send_message( + "You can't use these buttons, they're someone else's!", + ephemeral=True, + ) + return False diff --git a/lib/metadata.py b/lib/metadata.py deleted file mode 100644 index 6d5c804..0000000 --- a/lib/metadata.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess - - -def get_latest_git_tag(): - """ - Retrieves the latest git tag. - """ - try: - command = ["git", "describe", "--abbrev=0", "--tags"] - return subprocess.check_output(command).decode().strip() - except subprocess.CalledProcessError as e: - print(f"Error: {e}") - return "BETA" - - -__title__ = "Luminara" -__version__ = get_latest_git_tag() -__author__ = "wlinator" -__license__ = "MIT License" diff --git a/lib/time.py b/lib/time.py index e553cd8..9d0b2f5 100644 --- a/lib/time.py +++ b/lib/time.py @@ -16,7 +16,4 @@ def seconds_until(hours, minutes): if future_exec < now: future_exec += datetime.timedelta(days=1) - # Calculate the time difference in seconds - seconds_until_execution = (future_exec - now).total_seconds() - - return seconds_until_execution + return (future_exec - now).total_seconds() diff --git a/modules/misc/info.py b/modules/misc/info.py index 5a9fd95..3d991af 100644 --- a/modules/misc/info.py +++ b/modules/misc/info.py @@ -5,7 +5,6 @@ import discord import psutil from discord.ext import bridge -from lib import metadata from lib.constants import CONST from lib.embed_builder import EmbedBuilder from services.currency_service import Currency @@ -34,7 +33,7 @@ async def cmd(self, ctx: bridge.Context, unix_timestamp: int) -> None: show_name=False, ) embed.set_author( - name=f"{metadata.__title__} v{metadata.__version__}", + name=f"{CONST.TITLE} v{CONST.VERSION}", url=CONST.REPO_URL, icon_url=CONST.CHECK_ICON, ) diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/services/currency_service.py b/services/currency_service.py index 45c7866..314fd5d 100644 --- a/services/currency_service.py +++ b/services/currency_service.py @@ -57,15 +57,7 @@ class Currency: query = "SELECT user_id, balance FROM currency ORDER BY balance DESC" data = database.select_query(query) - leaderboard = [] - rank = 1 - for row in data: - row_user_id = row[0] - balance = row[1] - leaderboard.append((row_user_id, balance, rank)) - rank += 1 - - return leaderboard + return [(row[0], row[1], rank) for rank, row in enumerate(data, start=1)] @staticmethod def format(num):