150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
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)) |