diff --git a/cogs/uptime/config.json b/cogs/uptime/config.json new file mode 100644 index 0000000..b228d1f --- /dev/null +++ b/cogs/uptime/config.json @@ -0,0 +1,23 @@ +{ + "description": "Bot Uptime", + "usages": ["!uptime"], + "first_seen": null, + "total_downtime": 0.0, + "last_start": null, + + "labels": { + "days": ["day", "days", "days"], + "hours": ["hour", "hours", "hours"], + "minutes": ["minute", "minutes", "minutes"], + "seconds": ["second", "seconds","seconds"] + }, + + "embed": { + "session_field": "Current Session", + "availability_field":"Total Uptime" + }, + "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