commit 6c9a482ee71b60cd7e0a795d54687a5cf796cf82 Author: rejnronuz Date: Fri Apr 3 21:24:24 2026 +0500 cogs refactoring, removing useless bloat, main.py refactor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35961c --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +.env +bot.log +__pycache__/ +*.pyc + +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +.vscode/ +.idea/ +*.swp +*.swo +*.db +.venv + +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccb99ad --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# muzovkantV2 +### shitty (half-)vibecoded discord bot +## overall +you can use this as a base for your discord bot by writing your own [cogs](/cogs/), or as is. + +## installation +1. clone the repo +```bash +git clone https://github.com/rejnronuzz/muzovkantv2.git +cd muzovkantv2 +``` +2. install the requirements. you might need to enter a venv on some systems. +```bash +pip install -r requirements.txt +``` +3. insert your discord bot token and [thecatapi](https://thecatapi.com) token into the .env file. +*it should look something like this:* +``` +DISCORD_TOKEN=abc123abc +CAT_API_KEY=live_abc123abc +``` +you can then edit the [config.py](/config.py) to your liking. + +## usage +the bot is running when the main.py script is running. + +### systemd service +you can configure a systemd service for this bot. +[systemd_service.sh](systemd_service.sh) is ONLY for systemd systems. + +(*tested on Ubuntu 24.04*) + +simply do: +``` +chmod +x systemd_service.sh +./systemd_service.sh +``` +the systemd service will now start and auto start on reboot. + +### updating with systemd service +keep in mind that after updating with the systemd service enabled, you will need to restart the service. +so the update workflow looks something like this: +``` +git pull origin main +sudo systemctl restart muzovkantv2 +``` + +## contacting +if you have any feedback, contact me on github issues or/and discord. if you want to add any functions to this bot, make a PR. +when submitting a bug report, make sure to attach the bot.log files. they generate automatically in root directory. + +*discord: rejnronuz* + + +![yo](readme/pic.jpg) diff --git a/bot_config.json b/bot_config.json new file mode 100644 index 0000000..2f78cb5 --- /dev/null +++ b/bot_config.json @@ -0,0 +1,10 @@ +{ + "settings": { + "command_prefix": "!", + "sync_commands": true + }, + "cogs": [ + "cogs.uptime.uptime", + "cogs.help.help" + ] +} \ No newline at end of file diff --git a/cogs/__init__.py b/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cogs/help/config.json b/cogs/help/config.json new file mode 100644 index 0000000..1d1ab45 --- /dev/null +++ b/cogs/help/config.json @@ -0,0 +1,15 @@ +{ + "description": "хмм даже не знаюююююю", + "usages": ["help"], + "hidden": false, + "messages": { + "upper_desc": "команды:", + "footer": "что то поломалось - пинг меня\nесли имеются предложения по улучшению, в лс или пинг", + "pfx_message": "префикс:" + }, + "settings": { + "command_pfx": "!", + "cogs_dir": "..", + "column_width": 20 + } +} \ No newline at end of file diff --git a/cogs/help/help.py b/cogs/help/help.py new file mode 100644 index 0000000..c7b9bcb --- /dev/null +++ b/cogs/help/help.py @@ -0,0 +1,107 @@ +import logging +from discord.ext import commands +import os +import json + +logger = logging.getLogger(__name__) + +CONFIG_FILE = 'config.json' +REQUIRED_MESSAGES = ['upper_desc', 'footer', 'pfx_message'] + +class HelpCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.help_config = self._load_and_validate_config() + self.cogs_dir = os.path.join( + os.path.dirname(__file__), + self.help_config["settings"]["cogs_dir"] + ) + + def _load_and_validate_config(self) -> dict: + path = os.path.join(os.path.dirname(__file__), CONFIG_FILE) + + if not os.path.exists(path): + raise FileNotFoundError(f"Help cog config not found at {path}") + + with open(path, 'r', encoding='utf-8') as f: + config = json.load(f) + + messages = config.get("messages", {}) + missing_msgs = [key for key in REQUIRED_MESSAGES if key not in messages] + if missing_msgs: + raise KeyError(f"Help config 'messages' is missing required keys: {missing_msgs}") + + settings = config.get("settings", {}) + settings.setdefault("cogs_dir", "..") + settings.setdefault("column_width", 20) + settings.setdefault("command_pfx", "!") + + config["messages"] = messages + config["settings"] = settings + + logger.info("Help cog config loaded successfully") + return config + + def _build_help_text(self, prefix: str) -> list[str]: + settings = self.help_config["settings"] + column_width = settings["column_width"] + lines = [] + + if not os.path.exists(self.cogs_dir): + logger.error(f"Cogs directory not found at: {self.cogs_dir}") + return lines + + for item in sorted(os.listdir(self.cogs_dir)): + cog_dir = os.path.join(self.cogs_dir, item) + if not os.path.isdir(cog_dir): + continue + + config_path = os.path.join(cog_dir, CONFIG_FILE) + if not os.path.exists(config_path): + continue + + try: + with open(config_path, 'r', encoding='utf-8') as f: + cog_config = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"Failed to load config for cog '{item}': {e}") + continue + + description = cog_config.get('description', '') + usages = cog_config.get('usages', []) + hidden = cog_config.get('hidden', False) + + if not usages or hidden: + continue + + for usage in usages: + usage_text = usage if usage.startswith(prefix) else f"{prefix}{usage}" + lines.append(f"{usage_text:<{column_width}} : {description}") + + return lines + + @commands.command(name="help") + async def help(self, ctx: commands.Context): + cfg_msgs = self.help_config["messages"] + cfg_sets = self.help_config["settings"] + + prefix = ctx.prefix or cfg_sets['command_pfx'] + lines = self._build_help_text(prefix) + + if not lines: + await ctx.send("No commands available.") + return + + help_text = ( + f"{cfg_msgs['upper_desc']}\n" + f"```\n" + f"{chr(10).join(lines)}\n" + f"```\n" + f"{cfg_msgs['pfx_message']} `{cfg_sets['command_pfx']}`\n" + f"{cfg_msgs['footer']}" + ) + + await ctx.send(help_text) + +async def setup(bot: commands.Bot): + await bot.add_cog(HelpCog(bot)) \ No newline at end of file diff --git a/cogs/uptime/config.json b/cogs/uptime/config.json new file mode 100644 index 0000000..524f63f --- /dev/null +++ b/cogs/uptime/config.json @@ -0,0 +1,23 @@ +{ + "description": "аптайм бота", + "usages": ["!uptime"], + "first_seen": null, + "total_downtime": 0.0, + "last_start": null, + + "labels": { + "days": ["день", "дня", "дней"], + "hours": ["час", "часа", "часов"], + "minutes": ["минуту", "минуты", "минут"], + "seconds": ["секунду", "секунды","секунд"] + }, + + "embed": { + "session_field": "текущая сессия", + "availability_field":"тотал аптайм" + }, + "settings": { + "max_units": 4, + "show_percent": true + } +} \ No newline at end of file diff --git a/cogs/uptime/uptime.py b/cogs/uptime/uptime.py new file mode 100644 index 0000000..71945a8 --- /dev/null +++ b/cogs/uptime/uptime.py @@ -0,0 +1,150 @@ +import discord +from discord.ext import commands +from datetime import datetime, timezone +import json +import os + +CONFIG_FILE = "config.json" +STATE_FILE = "uptime_state.json" + +REQUIRED_KEYS = ["labels", "embed", "settings"] +REQUIRED_LABEL_KEYS = ["days", "hours", "minutes", "seconds"] +REQUIRED_EMBED_KEYS = ["session_field", "availability_field"] + +def pluralize(n: int, forms: tuple[str, str, str]) -> str: + if 11 <= n % 100 <= 14: + return f"{n} {forms[2]}" + r = n % 10 + if r == 1: return f"{n} {forms[0]}" + if 2 <= r <= 4: return f"{n} {forms[1]}" + return f"{n} {forms[2]}" + +def format_delta(total_seconds: int, labels: dict, max_units: int) -> str: + minutes, secs = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + + candidates = [ + (days, labels["days"]), + (hours, labels["hours"]), + (minutes, labels["minutes"]), + (secs, labels["seconds"]), + ] + + parts = [pluralize(v, tuple(f)) for v, f in candidates if v][:max_units] + return " ".join(parts) if parts else pluralize(0, tuple(labels["seconds"])) + +def _now_ts() -> float: + return datetime.now(timezone.utc).timestamp() + +class UptimeSimple(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.session_start = _now_ts() + self.config = self._load_and_validate_config() + self.state = self._load_state() + + if self.state["last_start"] is not None: + gap = self.session_start - self.state["last_start"] + if gap > 5: + self.state["total_downtime"] += gap + + if self.state["first_seen"] is None: + self.state["first_seen"] = self.session_start + + self.state["last_start"] = self.session_start + self._save_state() + + def _parse_color(self, color_val) -> int: + if isinstance(color_val, str): + return int(color_val.lstrip("#").replace("0x", ""), 16) + return int(color_val) + + def _load_and_validate_config(self) -> dict: + path = os.path.join(os.path.dirname(__file__), CONFIG_FILE) + + if not os.path.exists(path): + raise FileNotFoundError(f"Uptime cog config not found at {path}") + + with open(path, "r", encoding="utf-8") as f: + config = json.load(f) + + missing = [k for k in REQUIRED_KEYS if k not in config] + if missing: + raise KeyError(f"Uptime config is missing required keys: {missing}") + + missing_labels = [k for k in REQUIRED_LABEL_KEYS if k not in config["labels"]] + if missing_labels: + raise KeyError(f"Uptime config 'labels' is missing keys: {missing_labels}") + + missing_embed = [k for k in REQUIRED_EMBED_KEYS if k not in config["embed"]] + if missing_embed: + raise KeyError(f"Uptime config 'embed' is missing keys: {missing_embed}") + + settings = config.get("settings", {}) + settings.setdefault("max_units", 4) + settings.setdefault("show_percent", True) + + color_val = config["embed"].get("color", 0x2ecc71) + config["embed"]["parsed_color"] = discord.Color(self._parse_color(color_val)) + + return config + + def _state_path(self) -> str: + return os.path.join(os.path.dirname(__file__), STATE_FILE) + + def _load_state(self) -> dict: + path = self._state_path() + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + return { + "first_seen": None, + "total_downtime": 0.0, + "last_start": None, + } + + def _save_state(self) -> None: + with open(self._state_path(), "w", encoding="utf-8") as f: + json.dump(self.state, f, indent=2) + + def availability_percent(self) -> float: + now = _now_ts() + total_span = now - self.state["first_seen"] + if total_span <= 0: + return 100.0 + return max(0.0, (total_span - self.state["total_downtime"]) / total_span * 100) + + @staticmethod + def _pct_bar(pct: float, width: int = 10) -> str: + filled = round(pct / 100 * width) + return "█" * filled + "░" * (width - filled) + + @commands.command(name="uptime") + async def uptime(self, ctx: commands.Context): + session_seconds = int(_now_ts() - self.session_start) + settings = self.config["settings"] + + embed = discord.Embed(color=self.config["embed"]["parsed_color"]) + embed.add_field( + name = self.config["embed"]["session_field"], + value = format_delta(session_seconds, self.config["labels"], settings["max_units"]), + inline = False, + ) + + if settings["show_percent"]: + pct = self.availability_percent() + embed.add_field( + name = self.config["embed"]["availability_field"], + value = f"{self._pct_bar(pct)} {pct:.2f}%", + inline = False, + ) + + await ctx.send(embed=embed) + + async def cog_unload(self) -> None: + self.state["last_start"] = None + self._save_state() + +async def setup(bot: commands.Bot): + await bot.add_cog(UptimeSimple(bot)) \ No newline at end of file diff --git a/cogs/uptime/uptime_state.json b/cogs/uptime/uptime_state.json new file mode 100644 index 0000000..1079da5 --- /dev/null +++ b/cogs/uptime/uptime_state.json @@ -0,0 +1,5 @@ +{ + "first_seen": 1775231576.45808, + "total_downtime": 1324.356654882431, + "last_start": 1775232900.814735 +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..db38762 --- /dev/null +++ b/main.py @@ -0,0 +1,101 @@ +import discord +from discord.ext import commands +import asyncio +import logging +import os +import json +from dotenv import load_dotenv + +CONFIG_FILE = 'bot_config.json' +logger = logging.getLogger(__name__) + +def setup_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('bot.log', encoding='utf-8'), + logging.StreamHandler() + ] + ) + +def load_config() -> dict: + if not os.path.exists(CONFIG_FILE): + raise FileNotFoundError(f"Main bot config not found at {CONFIG_FILE}") + + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Apply fallbacks + settings = config.get("settings", {}) + settings.setdefault("command_prefix", "!") + settings.setdefault("sync_commands", True) + config["settings"] = settings + config.setdefault("cogs", []) + + return config + +class Bot(commands.Bot): + def __init__(self, config: dict): + self.config = config + + intents = discord.Intents.default() + intents.message_content = True + intents.reactions = True + intents.members = True + intents.guilds = True + intents.messages = True + + super().__init__( + command_prefix=self.config["settings"]["command_prefix"], + intents=intents, + help_command=None, + ) + + async def setup_hook(self) -> None: + for cog in self.config["cogs"]: + try: + await self.load_extension(cog) + logger.info("Loaded cog: %s", cog) + except Exception as e: + logger.error("Failed to load cog %s: %s", cog, e, exc_info=True) + + if self.config["settings"]["sync_commands"]: + try: + await self.tree.sync() + logger.info("Application commands synced successfully") + except Exception as e: + logger.error("Failed to sync application commands: %s", e) + + async def on_ready(self) -> None: + if not hasattr(self, '_ready_fired'): + self._ready_fired = True + logger.info("Bot ready: %s (id: %s)", self.user, self.user.id) + +async def main() -> None: + setup_logging() + load_dotenv() + + token = os.getenv('DISCORD_TOKEN') + if not token: + logger.critical("DISCORD_TOKEN environment variable is missing; Shutting down") + return + + try: + config = load_config() + except Exception as e: + logger.critical("Failed to load configuration: %s", e) + return + + bot = Bot(config) + + try: + await bot.start(token) + except discord.LoginFailure: + logger.critical("Improper DISCORD_TOKEN passed; Shutting down") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Bot shutdown via keyboard interrupt") \ No newline at end of file diff --git a/readme/pic.jpg b/readme/pic.jpg new file mode 100644 index 0000000..7b7a8ab Binary files /dev/null and b/readme/pic.jpg differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad8bf30 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-dotenv>=1.0.0 +discord.py>=2.3.0 +aiofiles +aiosqlite \ No newline at end of file diff --git a/systemd_service.sh b/systemd_service.sh new file mode 100644 index 0000000..fd2eba9 --- /dev/null +++ b/systemd_service.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +BOT_DIR="$(cd "$(dirname "$0")" && pwd)" +BOT_USER="$(whoami)" +VENV_PYTHON="$BOT_DIR/venv/bin/python" +SERVICE_NAME="muzovkantv2" + +echo "creating systemd service for user '$BOT_USER' in '$BOT_DIR'..." + +sudo tee /etc/systemd/system/$SERVICE_NAME.service > /dev/null <