From 8eda8cedd979159a5bafdb7dff69bc72cc3b59e0 Mon Sep 17 00:00:00 2001 From: jackra1n <45038833+jackra1n@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:56:34 +0100 Subject: [PATCH] Restructure project - Start using __init__.py - Remove the "bot" directory from root and move everything from inside to root - Adjust .gitignore to ignore pycharm files - Use discord.utils to setup logging - Change boot up art and version display - Remove version.py, start.sh - Add colorlog --- .gitignore | 15 +++- Dockerfile | 2 +- bot/core/version.py | 54 -------------- bot/main.py | 82 --------------------- bot/start.sh | 6 -- bot/utils/art.txt | 6 -- bot/utils/db.py | 85 ---------------------- bot/utils/util.py | 47 ------------ compose.yaml | 4 +- core/__init__.py | 6 ++ {bot/core => core}/bot.py | 51 +++---------- {bot/core => core}/config.py.example | 4 +- bot/core/values.py => core/constants.py | 0 {bot/utils => core}/custom_logger.py | 0 {bot/core => core}/events.py | 7 +- database/__init__.py | 93 ++++++++++++++++++++++++ database/db_constants.py | 28 +++++++ extensions/__init__.py | 47 ++++++++++++ {bot/cogs => extensions}/feedback.py | 3 +- {bot/cogs => extensions}/free_games.py | 3 +- {bot/cogs => extensions}/fun.py | 3 +- {bot/cogs => extensions}/help.py | 7 +- {bot/cogs => extensions}/karma.py | 28 +++---- {bot/cogs => extensions}/music.py | 5 +- {bot/cogs => extensions}/owner.py | 34 ++------- {bot/cogs => extensions}/util.py | 22 +++--- main.py | 21 ++++++ pyproject.toml | 17 +++++ requirements.txt | 3 +- {bot/db => resources}/CreateDatabase.sql | 0 utils/__init__.py | 1 + utils/ux/__init__.py | 50 +++++++++++++ utils/ux/art.txt | 13 ++++ {bot/utils => utils/ux}/colors.py | 0 34 files changed, 358 insertions(+), 389 deletions(-) delete mode 100644 bot/core/version.py delete mode 100644 bot/main.py delete mode 100755 bot/start.sh delete mode 100644 bot/utils/art.txt delete mode 100644 bot/utils/db.py delete mode 100644 bot/utils/util.py create mode 100644 core/__init__.py rename {bot/core => core}/bot.py (72%) rename {bot/core => core}/config.py.example (55%) rename bot/core/values.py => core/constants.py (100%) rename {bot/utils => core}/custom_logger.py (100%) rename {bot/core => core}/events.py (95%) create mode 100644 database/__init__.py create mode 100644 database/db_constants.py create mode 100644 extensions/__init__.py rename {bot/cogs => extensions}/feedback.py (99%) rename {bot/cogs => extensions}/free_games.py (99%) rename {bot/cogs => extensions}/fun.py (99%) rename {bot/cogs => extensions}/help.py (97%) rename {bot/cogs => extensions}/karma.py (98%) rename {bot/cogs => extensions}/music.py (99%) rename {bot/cogs => extensions}/owner.py (92%) rename {bot/cogs => extensions}/util.py (96%) create mode 100644 main.py rename {bot/db => resources}/CreateDatabase.sql (100%) create mode 100644 utils/__init__.py create mode 100644 utils/ux/__init__.py create mode 100644 utils/ux/art.txt rename {bot/utils => utils/ux}/colors.py (100%) diff --git a/.gitignore b/.gitignore index ec43c0d..858b6ff 100644 --- a/.gitignore +++ b/.gitignore @@ -137,10 +137,21 @@ dmypy.json # Cython debug symbols cython_debug/ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + # substiify custom -bot/core/version.toml -bot/core/config.py +core/version.toml +config.py postgres-data logs/ diff --git a/Dockerfile b/Dockerfile index 75f2ba9..7926730 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python", "bot/main.py"] \ No newline at end of file +CMD ["python", "main.py"] \ No newline at end of file diff --git a/bot/core/version.py b/bot/core/version.py deleted file mode 100644 index 6fcd207..0000000 --- a/bot/core/version.py +++ /dev/null @@ -1,54 +0,0 @@ -import subprocess -from enum import Enum, auto - -import toml -from core import values - -default_version_dict = {"major": 0, "minor": 1} - - -class VersionType(Enum): - MAJOR = 'major' - MINOR = 'minor' - PATCH = auto() - LAST_UPDATE = 'last_update' - - -class Version(): - def __init__(self): - self._set_properties(toml.load(values.VERSION_CONFIG_PATH)) - - def get(self, version_type: VersionType | None = None) -> str: - if version_type: - return self.version_dict[version_type.value] - else: - return f'{self.major}.{self.minor}.{self.patch}' - - def set(self, version_type: VersionType, version_value: int) -> None: - self.version_dict[version_type.value] = version_value - if version_type is VersionType.MAJOR: - self.version_dict[VersionType.MINOR.value] = 0 - last_update = Version._get_last_commit_sha() - self.version_dict[VersionType.LAST_UPDATE.value] = last_update - self._set_properties(self.version_dict) - with open(values.VERSION_CONFIG_PATH, "w") as toml_file: - toml.dump(self.version_dict, toml_file) - - def create_version_file() -> None: - last_update = Version._get_last_commit_sha() - default_version_dict[VersionType.LAST_UPDATE.value] = last_update - with open(values.VERSION_CONFIG_PATH, "w") as toml_file: - toml.dump(default_version_dict, toml_file) - - def _set_properties(self, version_dict) -> None: - self.version_dict = version_dict - self.major = self.version_dict[VersionType.MAJOR.value] - self.minor = self.version_dict[VersionType.MINOR.value] - self.last_update = self.version_dict[VersionType.LAST_UPDATE.value] - self.patch = self._calculate_patch() - - def _calculate_patch(self) -> int: - return int(subprocess.check_output(['/usr/bin/git', 'rev-list', f'{self.last_update}..HEAD', '--count']).decode('utf-8').strip()) - - def _get_last_commit_sha() -> str: - return subprocess.check_output(['/usr/bin/git', 'rev-parse', 'HEAD']).decode('utf-8').strip() diff --git a/bot/main.py b/bot/main.py deleted file mode 100644 index 4a0cca7..0000000 --- a/bot/main.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio -import logging -from logging.handlers import TimedRotatingFileHandler -from pathlib import Path - -import asyncpg -from core import values -from core.bot import Substiify -from core.version import Version -from utils import util -from utils.custom_logger import CustomLogFormatter, RemoveNoise -from utils.db import Database - -logger = logging.getLogger(__name__) - -try: - from core import config -except ImportError: - logger.warning("No config.py found. Make sure to copy config.py.example to config.py and fill in the values.") - exit() - - -def prepare_files() -> None: - # Create 'logs' folder if it doesn't exist - Path(values.LOGS_PATH).mkdir(parents=True, exist_ok=True) - - if not Path(values.VERSION_CONFIG_PATH).is_file(): - Version.create_version_file() - - logger.info('All system files ready') - - -def setup_logging() -> None: - logging.getLogger('discord.gateway').addFilter(RemoveNoise()) - log = logging.getLogger() - log.setLevel(logging.INFO) - - stream_handler = logging.StreamHandler() - stream_handler.setFormatter(CustomLogFormatter()) - log.addHandler(stream_handler) - - file_handler = TimedRotatingFileHandler(f'{values.LOGS_PATH}/substiify', when="midnight", interval=1, encoding='utf-8') - file_formatter = logging.Formatter('[{asctime}] [{levelname:<7}] {name}: {message}', '%Y-%m-%d %H:%M:%S', style='{') - file_handler.suffix = "%Y-%m-%d.log" - file_handler.setFormatter(file_formatter) - log.addHandler(file_handler) - logger.info('Logging setup finished') - - -if not config.TOKEN: - logger.error('No token in config.py! Please add it and try again.') - exit() - - -async def main(): - try: - con: asyncpg.Connection = await asyncpg.connect(dsn=config.POSTGRESQL_DSN) - await con.close() - except Exception as error: - logger.error(f"Could not connect to database: {error}") - exit() - - util.print_system_info() - - async with Substiify() as substiify, asyncpg.create_pool( - dsn=config.POSTGRESQL_DSN, max_inactive_connection_lifetime=0 - ) as pool: - if pool is None: - raise RuntimeError("Could not connect to database.") - - substiify.db = Database(substiify, pool) - await substiify.db.create_database() - await substiify.start(config.TOKEN) - -if __name__ == "__main__": - prepare_files() - setup_logging() - - try: - asyncio.run(main()) - except KeyboardInterrupt: - pass diff --git a/bot/start.sh b/bot/start.sh deleted file mode 100755 index d464857..0000000 --- a/bot/start.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# start bot in a new screen - -screen -dmS substiify python3 bot.py -screen -t substiify -X multiuser on diff --git a/bot/utils/art.txt b/bot/utils/art.txt deleted file mode 100644 index 6bb6e95..0000000 --- a/bot/utils/art.txt +++ /dev/null @@ -1,6 +0,0 @@ - __ __ _ _ ____ - _______ __/ /_ _____/ /_(_|_) __/_ __ - / ___/ / / / __ \/ ___/ __/ / / /_/ / / / - (__ ) /_/ / /_/ (__ ) /_/ / / __/ /_/ / -/____/\__,_/_.___/____/\__/_/_/_/ \__, / - /____/ \ No newline at end of file diff --git a/bot/utils/db.py b/bot/utils/db.py deleted file mode 100644 index 160f990..0000000 --- a/bot/utils/db.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -from pathlib import Path - -import asyncpg -import discord -from asyncpg import Record - -logger = logging.getLogger(__name__) - -USER_INSERT_QUERY = """INSERT INTO discord_user - (discord_user_id, username, avatar) - VALUES ($1, $2, $3) - ON CONFLICT (discord_user_id) DO UPDATE - SET - username = EXCLUDED.username, - avatar = EXCLUDED.avatar - """ - -SERVER_INSERT_QUERY = """INSERT INTO discord_server - (discord_server_id, server_name) - VALUES ($1, $2) - ON CONFLICT (discord_server_id) DO UPDATE - SET - server_name = EXCLUDED.server_name - """ - -CHANNEL_INSERT_QUERY = """INSERT INTO discord_channel - (discord_channel_id, channel_name, discord_server_id, parent_discord_channel_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (discord_channel_id) DO UPDATE - SET - channel_name = EXCLUDED.channel_name, - parent_discord_channel_id = EXCLUDED.parent_discord_channel_id - """ - - -class Database: - def __init__(self, bot: discord.Client, pool: asyncpg.Pool) -> None: - self.bot = bot - self.pool = pool - - def _transaction(call): - def decorator(func): - async def wrapper(self, query, *args, **kwargs): - async with self.pool.acquire() as connection: - async with connection.transaction(): - return await getattr(connection, call)(query, *args, **kwargs) - return wrapper - return decorator - - @_transaction("execute") - async def execute(self, query, *args, **kwargs) -> str: - pass - - @_transaction("executemany") - async def executemany(self, query, *args, **kwargs) -> None: - pass - - @_transaction("fetch") - async def fetch(self, query, *args, **kwargs) -> list: - pass - - @_transaction("fetchrow") - async def fetchrow(self, query, *args, **kwargs) -> Record | None: - pass - - @_transaction("fetchval") - async def fetchval(self, query, *args, **kwargs): - pass - - async def _insert_foundation(self, user: discord.Member, server: discord.Guild, channel: discord.abc.Messageable): - avatar_url = user.display_avatar.url if user.display_avatar else None - await self.execute(USER_INSERT_QUERY, user.id, user.name, avatar_url) - await self.execute(SERVER_INSERT_QUERY, server.id, server.name) - - if pchannel := channel.parent if isinstance(channel, discord.Thread) else None: - await self.execute(CHANNEL_INSERT_QUERY, pchannel.id, pchannel.name, pchannel.guild.id, None) - - p_chan_id = pchannel.id if pchannel else None - await self.execute(CHANNEL_INSERT_QUERY, channel.id, channel.name, channel.guild.id, p_chan_id) - - # Creates database tables if they don't exist - async def create_database(self): - db_script = Path("./bot/db/CreateDatabase.sql").read_text('utf-8') - await self.execute(db_script) \ No newline at end of file diff --git a/bot/utils/util.py b/bot/utils/util.py deleted file mode 100644 index 136efc6..0000000 --- a/bot/utils/util.py +++ /dev/null @@ -1,47 +0,0 @@ -import importlib.resources -import platform -import re -import shutil - -import discord -from core.version import Version -from utils.colors import Colors, get_colored - - -def get_system_description() -> str: - system_bits = (platform.machine(), platform.system(), platform.release()) - filtered_system_bits = (s.strip() for s in system_bits if s.strip()) - return " ".join(filtered_system_bits) - - -def print_system_info() -> None: - system_label = get_colored("Running on:", Colors.green, True) - python_label = get_colored("Python:", Colors.cyan, True) - discord_label = get_colored("discord.py:", Colors.yellow, True) - substiify_label = get_colored("substiify:", Colors.red, True) - - ascii_art = importlib.resources.read_text("utils", "art.txt") - shell_width = shutil.get_terminal_size().columns - center_art = shell_width - (shell_width // 15) - for line in ascii_art.splitlines(): - print(line.center(center_art)) - - system = get_system_description() - system_length = len(system) - longest_label = len(f'{system_label} {system}') - - rjust_len = (shell_width // 2) + (longest_label // 2) - python_version = platform.python_version().rjust(system_length) - discord_version = discord.__version__.rjust(system_length) - substiify_version = Version().get().rjust(system_length) - - print() - print(f'{system_label} {system}'.rjust(rjust_len)) - print(f'{python_label} {python_version}'.rjust(rjust_len)) - print(f'{discord_label} {discord_version}'.rjust(rjust_len)) - print(f'{substiify_label} {substiify_version}'.rjust(rjust_len)) - print() - -def strip_emotes(string: str) -> str: - discord_emote_pattern = re.compile(r"") - return discord_emote_pattern.sub('', string) \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index d77324f..857fccb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,6 +32,6 @@ services: - postgres restart: unless-stopped volumes: - - ./bot/logs:/bot/logs - - ./bot/core/version.toml:/bot/core/version.toml + - ./logs:/bot/logs + - ./core/version.toml:/bot/core/version.toml - /etc/localtime:/etc/localtime:ro diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..935bfe6 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,6 @@ +from .bot import Substiify +from .config import * +from .constants import * + +__version__ = "0.96" +__author__ = "jackra1n" \ No newline at end of file diff --git a/bot/core/bot.py b/core/bot.py similarity index 72% rename from bot/core/bot.py rename to core/bot.py index 332bd12..fa33922 100644 --- a/bot/core/bot.py +++ b/core/bot.py @@ -3,68 +3,41 @@ import discord import wavelink -from core import config -from core.version import Version from discord.ext import commands -from utils.db import Database -logger = logging.getLogger(__name__) - -INITIAL_EXTENSIONS = [ - 'core.events', - 'cogs.feedback', - 'cogs.free_games', - 'cogs.fun', - 'cogs.help', - 'cogs.karma', - 'cogs.music', - 'cogs.util', - 'cogs.owner', - 'jishaku' -] +import core +from database import Database -try: - import jishaku -except ModuleNotFoundError: - INITIAL_EXTENSIONS.remove('jishaku') -else: - del jishaku +logger = logging.getLogger(__name__) class Substiify(commands.Bot): - - db: Database - start_time: datetime.datetime - - def __init__(self) -> None: + def __init__(self, *, database: Database) -> None: + self.db = database + self.version = core.__version__ + self.start_time = datetime.datetime.now(datetime.timezone.utc) intents = discord.Intents().all() super().__init__( - command_prefix=commands.when_mentioned_or(config.PREFIX), + command_prefix=commands.when_mentioned_or(core.config.PREFIX), intents=intents, owner_id=276462585690193921, max_messages=3000 ) - self.version = Version() async def setup_hook(self) -> None: - self.start_time: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) + await self.load_extension('core.events') + await self.load_extension('extensions') - node: wavelink.Node = wavelink.Node(uri=config.LAVALINK_NODE_URL, password=config.LAVALINK_PASSWORD) + node: wavelink.Node = wavelink.Node(uri=core.config.LAVALINK_NODE_URL, password=core.config.LAVALINK_PASSWORD) await wavelink.Pool.connect(client=self, nodes=[node]) - for extension in INITIAL_EXTENSIONS: - try: - await self.load_extension(extension) - except Exception as error: - exc = f'{type(error).__name__}: {error}' - logger.warning(f'Failed to load extension {extension}\n{exc}') async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload) -> None: logging.info(f"Wavelink Node connected: {payload.node!r} | Resumed: {payload.resumed}") async def on_ready(self: commands.Bot) -> None: servers = len(self.guilds) - activity_name = f"{config.PREFIX}help | {servers} servers" + activity_name = f"{core.config.PREFIX}help | {servers} servers" activity = discord.Activity(type=discord.ActivityType.listening, name=activity_name) await self.change_presence(activity=activity) logger.info(f'Logged on as {self.user} (ID: {self.user.id})') diff --git a/bot/core/config.py.example b/core/config.py.example similarity index 55% rename from bot/core/config.py.example rename to core/config.py.example index 332897a..8cb0fed 100644 --- a/bot/core/config.py.example +++ b/core/config.py.example @@ -3,5 +3,5 @@ PREFIX = "" POSTGRESQL_DSN = "postgresql://[user]:[password]@[host]:[port]/[database_name]" -LAVALINK_NODE_URL = "" -LAVALINK_PASSWORD = "" +LAVALINK_NODE_URL = "http://localhost:2333/" +LAVALINK_PASSWORD = "youshallnotpass" diff --git a/bot/core/values.py b/core/constants.py similarity index 100% rename from bot/core/values.py rename to core/constants.py diff --git a/bot/utils/custom_logger.py b/core/custom_logger.py similarity index 100% rename from bot/utils/custom_logger.py rename to core/custom_logger.py diff --git a/bot/core/events.py b/core/events.py similarity index 95% rename from bot/core/events.py rename to core/events.py index a1cffdc..64de574 100644 --- a/bot/core/events.py +++ b/core/events.py @@ -1,9 +1,10 @@ import logging import discord -from core.bot import Substiify from discord.ext import commands +import core + EVENTS_CHANNEL_ID = 1131685580300877916 logger = logging.getLogger(__name__) @@ -11,7 +12,7 @@ class Events(commands.Cog): - def __init__(self, bot: Substiify): + def __init__(self, bot: core.Substiify): self.bot = bot @commands.Cog.listener() @@ -65,5 +66,5 @@ async def _insert_channel(self, channel: discord.abc.GuildChannel): await self.bot.db.execute(stmt, channel.id, channel.name, channel.guild.id) -async def setup(bot: Substiify): +async def setup(bot: core.Substiify): await bot.add_cog(Events(bot)) \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..86f3aee --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,93 @@ +import asyncio +import logging +import os +from typing import TYPE_CHECKING, Any, Self + +import asyncpg +import discord +from database.db_constants import (CHANNEL_INSERT_QUERY, SERVER_INSERT_QUERY, + USER_INSERT_QUERY) + +import core + +if TYPE_CHECKING: + _Pool = asyncpg.Pool[asyncpg.Record] +else: + _Pool = asyncpg.Pool + + +__all__ = ("Database",) + + +logger: logging.Logger = logging.getLogger(__name__) + + +class Database: + pool: _Pool + + async def __aenter__(self) -> Self: + await self.setup() + return self + + async def __aexit__(self, *args: Any) -> None: + try: + await asyncio.wait_for(self.pool.close(), timeout=10) + except TimeoutError: + logger.warning("Unable to gracefully shutdown database connection, forcefully continuing.") + else: + logger.info("Successfully closed Database connection.") + + async def setup(self) -> None: + pool: _Pool | None = await asyncpg.create_pool(dsn=core.config.POSTGRESQL_DSN) + + if pool is None: + raise RuntimeError('Unable to intialise the Database, "create_pool" returned None.') + + self.pool = pool + + db_schema = os.path.join("resources","CreateDatabase.sql") + with open(db_schema) as fp: + await self.pool.execute(fp.read()) + + logger.info("Successfully initialised the Database.") + + def _transaction(call): + def decorator(func): + async def wrapper(self, query, *args, **kwargs): + async with self.pool.acquire() as connection: + async with connection.transaction(): + return await getattr(connection, call)(query, *args, **kwargs) + return wrapper + return decorator + + @_transaction("execute") + async def execute(self, query, *args, **kwargs) -> str: + pass + + @_transaction("executemany") + async def executemany(self, query, *args, **kwargs) -> None: + pass + + @_transaction("fetch") + async def fetch(self, query, *args, **kwargs) -> list: + pass + + @_transaction("fetchrow") + async def fetchrow(self, query, *args, **kwargs) -> asyncpg.Record | None: + pass + + @_transaction("fetchval") + async def fetchval(self, query, *args, **kwargs): + pass + + async def _insert_foundation(db: asyncpg.Connection, user: discord.Member, server: discord.Guild, channel: discord.abc.Messageable): + avatar_url = user.display_avatar.url if user.display_avatar else None + await db.execute(USER_INSERT_QUERY, user.id, user.name, avatar_url) + await db.execute(SERVER_INSERT_QUERY, server.id, server.name) + + if pchannel := channel.parent if isinstance(channel, discord.Thread) else None: + await db.execute(CHANNEL_INSERT_QUERY, pchannel.id, pchannel.name, pchannel.guild.id, None) + + p_chan_id = pchannel.id if pchannel else None + await db.execute(CHANNEL_INSERT_QUERY, channel.id, channel.name, channel.guild.id, p_chan_id) + diff --git a/database/db_constants.py b/database/db_constants.py new file mode 100644 index 0000000..f75fc27 --- /dev/null +++ b/database/db_constants.py @@ -0,0 +1,28 @@ + +USER_INSERT_QUERY = """INSERT INTO discord_user + (discord_user_id, username, avatar) + VALUES ($1, $2, $3) + ON CONFLICT (discord_user_id) DO UPDATE + SET + username = EXCLUDED.username, + avatar = EXCLUDED.avatar + """ + +SERVER_INSERT_QUERY = """INSERT INTO discord_server + (discord_server_id, server_name) + VALUES ($1, $2) + ON CONFLICT (discord_server_id) DO UPDATE + SET + server_name = EXCLUDED.server_name + """ + +CHANNEL_INSERT_QUERY = """INSERT INTO discord_channel + (discord_channel_id, channel_name, discord_server_id, parent_discord_channel_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (discord_channel_id) DO UPDATE + SET + channel_name = EXCLUDED.channel_name, + parent_discord_channel_id = EXCLUDED.parent_discord_channel_id + """ + + diff --git a/extensions/__init__.py b/extensions/__init__.py new file mode 100644 index 0000000..21b2543 --- /dev/null +++ b/extensions/__init__.py @@ -0,0 +1,47 @@ +"""Copyright 2024 Mysty + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import logging +import pathlib + +from discord.ext import commands + +import core + +logger: logging.Logger = logging.getLogger(__name__) + + +async def setup(bot: core.Substiify) -> None: + extensions: list[str] = [f".{f.stem}" for f in pathlib.Path("extensions").glob("*[a-zA-Z].py")] + loaded: list[str] = [] + + for extension in extensions: + try: + await bot.load_extension(extension, package="extensions") + except Exception as e: + logger.error('Unable to load extension: "%s" > %s', extension, e) + else: + loaded.append(f"extensions{extension}") + + logger.info("Loaded the following extensions: %s", loaded) + + +async def teardown(bot: core.Substiify) -> None: + extensions: list[str] = [f".{f.stem}" for f in pathlib.Path("extensions").glob("*[a-zA-Z].py")] + + for extension in extensions: + try: + await bot.unload_extension(extension, package="extensions") + except commands.ExtensionNotLoaded: + pass \ No newline at end of file diff --git a/bot/cogs/feedback.py b/extensions/feedback.py similarity index 99% rename from bot/cogs/feedback.py rename to extensions/feedback.py index 55b6d9c..c6f8656 100644 --- a/bot/cogs/feedback.py +++ b/extensions/feedback.py @@ -3,10 +3,11 @@ import discord from asyncpg import Record -from core.bot import Substiify from discord import app_commands from discord.ext import commands +from core.bot import Substiify + logger = logging.getLogger(__name__) ACCEPT_EMOJI = discord.PartialEmoji.from_str('greenTick:876177251832590348') diff --git a/bot/cogs/free_games.py b/extensions/free_games.py similarity index 99% rename from bot/cogs/free_games.py rename to extensions/free_games.py index e88597e..ccd3192 100644 --- a/bot/cogs/free_games.py +++ b/extensions/free_games.py @@ -3,9 +3,10 @@ import aiohttp import discord -from core.bot import Substiify from discord.ext import commands +from core.bot import Substiify + logger = logging.getLogger(__name__) EPIC_STORE_FREE_GAMES_API = "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions" diff --git a/bot/cogs/fun.py b/extensions/fun.py similarity index 99% rename from bot/cogs/fun.py rename to extensions/fun.py index cd74937..c638208 100644 --- a/bot/cogs/fun.py +++ b/extensions/fun.py @@ -4,9 +4,10 @@ import discord import vacefron -from core.bot import Substiify from discord.ext import commands +from core.bot import Substiify + logger = logging.getLogger(__name__) diff --git a/bot/cogs/help.py b/extensions/help.py similarity index 97% rename from bot/cogs/help.py rename to extensions/help.py index 2185ff2..b2bf59b 100644 --- a/bot/cogs/help.py +++ b/extensions/help.py @@ -1,10 +1,11 @@ from typing import Optional, Set import discord -from core import values from discord import Embed from discord.ext import commands +from core import constants + ANSI_RESET = "\u001b[0;0m" ANSI_GRAY = "\u001b[0;30m" ANSI_GREEN = "\u001b[0;32m" @@ -62,7 +63,7 @@ async def _help_embed( self, title: str, description: Optional[str] = None, mapping: Optional[str] = None, command_set: Optional[Set[commands.Command]] = None, set_author: bool = False ) -> Embed: - embed = Embed(title=title, color=values.SECONDARY_COLOR) + embed = Embed(title=title, color=constants.SECONDARY_COLOR) if description: embed.description = description if set_author: @@ -118,7 +119,7 @@ def create_command_help_embed(self, command): usage = f"{command.full_parent_name} {usage}" usage = self.context.clean_prefix + usage - embed = Embed(title=command_name, color=values.SECONDARY_COLOR) + embed = Embed(title=command_name, color=constants.SECONDARY_COLOR) embed.add_field(name="Info", value=help_msg.replace("{prefix}", self.context.clean_prefix), inline=False) embed.add_field(name="Aliases", value=f"```asciidoc\n{aliases_msg}```") embed.add_field(name="Usage", value=f"```asciidoc\n{usage}```", inline=False) diff --git a/bot/cogs/karma.py b/extensions/karma.py similarity index 98% rename from bot/cogs/karma.py rename to extensions/karma.py index f483572..8b566c5 100644 --- a/bot/cogs/karma.py +++ b/extensions/karma.py @@ -5,11 +5,11 @@ import discord import plotly.graph_objects as go from asyncpg import Record -from core import values -from core.bot import Substiify from discord import app_commands from discord.ext import commands -from utils import util + +import core +import utils logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ class Karma(commands.Cog): COG_EMOJI = "☯️" - def __init__(self, bot: Substiify, vote_channels: list[int]): + def __init__(self, bot: core.Substiify, vote_channels: list[int]): self.bot = bot self.vote_channels = vote_channels @@ -41,10 +41,10 @@ async def on_message(self, message: discord.Message): pass def get_upvote_emote(self): - return self.bot.get_emoji(values.UPVOTE_EMOTE_ID) + return self.bot.get_emoji(core.constants.UPVOTE_EMOTE_ID) def get_downvote_emote(self): - return self.bot.get_emoji(values.DOWNVOTE_EMOTE_ID) + return self.bot.get_emoji(core.constants.DOWNVOTE_EMOTE_ID) @commands.hybrid_group(invoke_without_command=True) async def votes(self, ctx: commands.Context): @@ -749,14 +749,14 @@ async def _get_karma_upvote_emotes(self, guild_id: int) -> list[int]: stmt_upvotes = "SELECT discord_emote_id FROM karma_emote WHERE discord_server_id = $1 AND increase_karma = True" emote_records = await self.bot.db.fetch(stmt_upvotes, guild_id) server_upvote_emotes = [emote['discord_emote_id'] for emote in emote_records] - server_upvote_emotes.append(int(values.UPVOTE_EMOTE_ID)) + server_upvote_emotes.append(int(core.constants.UPVOTE_EMOTE_ID)) return server_upvote_emotes async def _get_karma_downvote_emotes(self, guild_id: int) -> list[int]: stmt_downvotes = "SELECT discord_emote_id FROM karma_emote WHERE discord_server_id = $1 AND increase_karma = False" emote_records = await self.bot.db.fetch(stmt_downvotes, guild_id) server_downvote_emotes = [emote['discord_emote_id'] for emote in emote_records] - server_downvote_emotes.append(int(values.DOWNVOTE_EMOTE_ID)) + server_downvote_emotes.append(int(core.constants.DOWNVOTE_EMOTE_ID)) return server_downvote_emotes async def check_payload(self, payload: discord.RawReactionActionEvent) -> discord.Member | None: @@ -884,7 +884,7 @@ async def win_kasino(self, kasino_id: int, winning_option: int): user = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id) await user.send(embed=output) -async def _update_kasino_msg(bot: Substiify, kasino_id: int) -> None: +async def _update_kasino_msg(bot: core.Substiify, kasino_id: int) -> None: kasino = await bot.db.fetchrow('SELECT * FROM kasino WHERE id = $1', kasino_id) kasino_channel = await bot.fetch_channel(kasino['discord_channel_id']) kasino_msg = await kasino_channel.fetch_message(kasino['discord_message_id']) @@ -940,7 +940,7 @@ def __init__(self, option: int): super().__init__(label=f'Bet: {option}', emoji=gamba_emoji, style=discord.ButtonStyle.blurple) async def callback(self, interaction: discord.Interaction): - bot: Substiify = interaction.client + bot: core.Substiify = interaction.client if self.view.kasino['locked']: return await interaction.response.send_message( 'The kasino is locked! No more bets are taken in. Time to wait and see...', @@ -971,7 +971,7 @@ def __init__(self, kasino: Record): super().__init__(label=label, emoji=emoji, style=style) async def callback(self, interaction: discord.Interaction): - bot: Substiify = interaction.client + bot: core.Substiify = interaction.client kasino_id = self.view.kasino['id'] if not interaction.user.guild_permissions.manage_channels and not await bot.is_owner(interaction.user): return await interaction.response.send_message('You don\'t have permission to lock the kasino!', ephemeral=True) @@ -987,7 +987,7 @@ async def callback(self, interaction: discord.Interaction): class KasinoBetModal(discord.ui.Modal): def __init__(self, kasino: Record, bettor_karma: int, user_bet: Record, option: int): - title = util.strip_emotes(kasino['question']) + title = utils.ux.strip_emotes(kasino['question']) if len(title) > 45: title = title[:42] + '...' super().__init__(title=title) @@ -1008,7 +1008,7 @@ def __init__(self, kasino: Record, bettor_karma: int, user_bet: Record, option: self.add_item(self.bet_amount_input) async def on_submit(self, interaction: discord.Interaction) -> None: - bot: Substiify = interaction.client + bot: core.Substiify = interaction.client kasino_id: int = self.kasino['id'] amount: int = self.bet_amount_input.value bettor_karma: int = await bot.db.fetchval('SELECT amount FROM karma WHERE discord_user_id = $1 AND discord_server_id = $2', interaction.user.id, interaction.guild.id) @@ -1052,7 +1052,7 @@ async def on_submit(self, interaction: discord.Interaction) -> None: await _update_kasino_msg(bot, kasino_id) -async def setup(bot: Substiify): +async def setup(bot: core.Substiify): query = await bot.db.fetch('SELECT * FROM discord_channel WHERE upvote = True') upvote_channels = [channel['discord_channel_id'] for channel in query] or [] await bot.add_cog(Karma(bot, upvote_channels)) diff --git a/bot/cogs/music.py b/extensions/music.py similarity index 99% rename from bot/cogs/music.py rename to extensions/music.py index a578786..dcc7c01 100644 --- a/bot/cogs/music.py +++ b/extensions/music.py @@ -3,11 +3,12 @@ import discord import wavelink -from core import config -from core.bot import Substiify from discord import ButtonStyle, Interaction, ui from discord.ext import commands +from core import config +from core.bot import Substiify + logger = logging.getLogger(__name__) EMBED_COLOR = 0x292B3E diff --git a/bot/cogs/owner.py b/extensions/owner.py similarity index 92% rename from bot/cogs/owner.py rename to extensions/owner.py index ffcd8c6..0e1dd48 100644 --- a/bot/cogs/owner.py +++ b/extensions/owner.py @@ -3,13 +3,12 @@ from typing import Literal, Optional import discord -from core import config, values -from core.bot import Substiify -from core.version import VersionType from discord import Activity, ActivityType from discord.ext import commands, tasks from discord.ext.commands import Greedy +import core + logger = logging.getLogger(__name__) @@ -17,7 +16,7 @@ class Owner(commands.Cog): COG_EMOJI = "👑" - def __init__(self, bot: Substiify): + def __init__(self, bot: core.Substiify): self.bot = bot self.status_task.start() self.message_server = None @@ -29,7 +28,7 @@ def __init__(self, bot: Substiify): async def set_default_status(self): if self.bot.is_ready(): servers = len(self.bot.guilds) - activity_name = f"{config.PREFIX}help | {servers} servers" + activity_name = f"{core.config.PREFIX}help | {servers} servers" activity = Activity(type=ActivityType.listening, name=activity_name) await self.bot.change_presence(activity=activity) @@ -188,23 +187,6 @@ async def version(self, ctx: commands.Context): embed.add_field(name='Current version', value=self.bot.version.get()) await ctx.send(embed=embed, delete_after=30) - @commands.is_owner() - @version.command(name='set') - async def set_version(self, ctx: commands.Context, version_type: VersionType, value: int): - """ - Sets the minor version. - """ - self.bot.version.set(version_type, value) - embed = discord.Embed(description=f'{version_type.value} version has been set to {value}') - await ctx.send(embed=embed, delete_after=30) - - @set_version.error - async def set_version_error(self, ctx: commands.Context, error): - if isinstance(error, commands.MissingRequiredArgument): - await ctx.send("Please specify the version type and value", delete_after=30) - if isinstance(error, commands.BadArgument): - await ctx.send("Version type not recognized. Version types are 'major' or 'minor'", delete_after=30) - @commands.group(name="usage", invoke_without_command=True) async def usage(self, ctx: commands.Context): """ @@ -248,7 +230,7 @@ async def usage_last(self, ctx: commands.Context, amount: int = 10): cmd = command['command_name'].center(longest_cmd) user = command['username'].center(longest_user) commands_used_string += f"`{user}` used `{cmd}` {discord_tmstmp}\n" - embed = discord.Embed(title=f"Last {amount} used commands on: **{ctx.guild.name}**", color=values.PRIMARY_COLOR) + embed = discord.Embed(title=f"Last {amount} used commands on: **{ctx.guild.name}**", color=core.constants.PRIMARY_COLOR) embed.description = commands_used_string await ctx.send(embed=embed, delete_after=60) await ctx.message.delete() @@ -272,7 +254,7 @@ async def usage_servers(self, ctx: commands.Context): for row in commands_used_query: commands_used += f"`{row['server_name']}`\n" commands_count += f"{row['count']}\n" - embed = discord.Embed(title="Top servers used commands", color=values.PRIMARY_COLOR) + embed = discord.Embed(title="Top servers used commands", color=core.constants.PRIMARY_COLOR) embed.add_field(name="Command", value=commands_used, inline=True) embed.add_field(name="Count", value=commands_count, inline=True) await ctx.send(embed=embed, delete_after=30) @@ -353,11 +335,11 @@ def create_command_usage_embed(results): for result in results: commands_used += f"`{result['command_name']}`\n" commands_count += f"{result['cnt']}\n" - embed = discord.Embed(color=values.PRIMARY_COLOR) + embed = discord.Embed(color=core.constants.PRIMARY_COLOR) embed.add_field(name="Command", value=commands_used, inline=True) embed.add_field(name="Count", value=commands_count, inline=True) return embed -async def setup(bot: Substiify): +async def setup(bot: core.Substiify): await bot.add_cog(Owner(bot)) diff --git a/bot/cogs/util.py b/extensions/util.py similarity index 96% rename from bot/cogs/util.py rename to extensions/util.py index 540d75b..ba757a2 100644 --- a/bot/cogs/util.py +++ b/extensions/util.py @@ -3,17 +3,18 @@ import logging import math import platform -import subprocess from random import SystemRandom, shuffle import discord import psutil -from core import values -from core.bot import Substiify +from utils import ux from discord import MessageType, app_commands from discord.ext import commands, tasks from pytz import timezone +from core import constants +from core.bot import Substiify + logger = logging.getLogger(__name__) @@ -328,7 +329,7 @@ async def ping(self, ctx: commands.Context): embed = discord.Embed( title=f'{title} 🏓', description=f'⏱️Ping: `{round(self.bot.latency*1000)}`ms', - color=values.PRIMARY_COLOR + color=constants.PRIMARY_COLOR ) await ctx.message.delete() await ctx.send(embed=embed) @@ -346,7 +347,7 @@ async def special_thanks(self, ctx: commands.Context): embed = discord.Embed( title="Special thanks for any help to those people", description=" ".join(peeople_who_helped), - color=values.PRIMARY_COLOR, + color=constants.PRIMARY_COLOR, ) await ctx.message.delete() @@ -359,20 +360,19 @@ async def info(self, ctx: commands.Context): """ content = '' bot_uptime = time_up((discord.utils.utcnow() - self.bot.start_time).total_seconds()) - git_log_cmd = ['git', 'log', '-1', '--date=format:"%Y/%m/%d"', '--format=%ad'] - last_commit_date = subprocess.check_output(git_log_cmd).decode('utf-8').strip().strip('"') + last_commit_hash = ux.get_last_commit_hash() cpu_percent = psutil.cpu_percent() ram = psutil.virtual_memory() ram_used = format_bytes((ram.total - ram.available)) ram_percent = psutil.virtual_memory().percent - bot_version = self.bot.version.get() proc = psutil.Process() with proc.oneshot(): memory = proc.memory_full_info() content = f'**Instance uptime:** `{bot_uptime}`\n' \ - f'**Version:** `{bot_version}` | **Updated:** `{last_commit_date}`\n' \ - f'**Python:** `{platform.python_version()}` | **discord.py:** `{discord.__version__}`\n\n' \ + f'**Version:** `{self.bot.version} [{last_commit_hash}]` \n' \ + f'**Python:** `{platform.python_version()}`\n' \ + f'**discord.py:** `{discord.__version__}`\n\n' \ f'**CPU:** `{cpu_percent}%`\n' \ f'**Process RAM:** `{format_bytes(memory.uss)}`\n' \ f'**Total RAM:** `{ram_used} ({ram_percent}%)`\n\n' \ @@ -381,7 +381,7 @@ async def info(self, ctx: commands.Context): embed = discord.Embed( title=f'Info about {self.bot.user.display_name}', description=content, - color=values.PRIMARY_COLOR, + color=constants.PRIMARY_COLOR, timestamp=datetime.datetime.now(timezone("Europe/Zurich")) ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) diff --git a/main.py b/main.py new file mode 100644 index 0000000..a35157c --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +import asyncio + +import discord + +import core +import database +import utils +from core.custom_logger import CustomLogFormatter + +discord.utils.setup_logging(formatter=CustomLogFormatter(), level=20) + + +async def main() -> None: + utils.print_system_info() + + async with database.Database() as db, core.Substiify(database=db) as substiify: + await substiify.start(core.config.TOKEN) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index c5c8cc9..e25d117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,20 @@ +[project] +name = "substiify" +version = "0.96" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "discord.py>=2.3.0", + "aiohttp>=3.7.4,<4", + "asyncpg>=0.29.0", + "wavelink>=3.2", + "pytz", + "toml", + "psutil", + "plotly", + "kaleido" +] + [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ diff --git a/requirements.txt b/requirements.txt index 75332f9..4056a65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,11 @@ discord.py[voice] asyncpg pytz +colorlog toml psutil wavelink -vacefron.py plotly kaleido +vacefron.py #jishaku # for debugging, not needed for normal use diff --git a/bot/db/CreateDatabase.sql b/resources/CreateDatabase.sql similarity index 100% rename from bot/db/CreateDatabase.sql rename to resources/CreateDatabase.sql diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..58a52b1 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +from .ux import * diff --git a/utils/ux/__init__.py b/utils/ux/__init__.py new file mode 100644 index 0000000..2e96e99 --- /dev/null +++ b/utils/ux/__init__.py @@ -0,0 +1,50 @@ +import importlib.resources +import platform +import re +import string +import sys +import subprocess + +import colorlog +import discord + +import core + +__all__ = ( + "print_system_info", + "strip_emotes" +) + + +def print_system_info() -> None: + raw_art = _read_art() + + system_bits = (platform.machine(), platform.system(), platform.release()) + filtered_system_bits = (s.strip() for s in system_bits if s.strip()) + bot_version = f'{core.__version__} [{get_last_commit_hash()}]' + + args = { + "system_description": " ".join(filtered_system_bits), + "python_version": platform.python_version(), + "discord_version": discord.__version__, + "substiify_version": bot_version + } + args.update(colorlog.escape_codes.escape_codes) + + art_str = string.Template(raw_art).substitute(args) + sys.stdout.write(art_str) + sys.stdout.flush() + + +def _read_art() -> str: + with importlib.resources.files("utils.ux").joinpath("art.txt").open() as file: + return file.read() + + +def strip_emotes(string: str) -> str: + discord_emote_pattern = re.compile(r"") + return discord_emote_pattern.sub('', string) + +def get_last_commit_hash() -> str: + git_log_cmd = ['git', 'log', '-1', '--pretty=format:"%h"'] + return subprocess.check_output(git_log_cmd).decode('utf-8').strip('"') \ No newline at end of file diff --git a/utils/ux/art.txt b/utils/ux/art.txt new file mode 100644 index 0000000..ee78004 --- /dev/null +++ b/utils/ux/art.txt @@ -0,0 +1,13 @@ + __ __ _ _ ____ + _______ __/ /_ _____/ /_(_|_) __/_ __ + / ___/ / / / __ \/ ___/ __/ / / /_/ / / / + (__ ) /_/ / /_/ (__ ) /_/ / / __/ /_/ / + /____/\__,_/_.___/____/\__/_/_/_/ \__, / + /____/ + +${green} ${bold}Running on:${reset} ${system_description} +${cyan} ${bold}Python:${reset} ${python_version} +${yellow} ${bold}discord.py:${reset} ${discord_version} +${red} ${bold}substiify:${reset} ${substiify_version} + + diff --git a/bot/utils/colors.py b/utils/ux/colors.py similarity index 100% rename from bot/utils/colors.py rename to utils/ux/colors.py