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))