163 lines
4.9 KiB
Python
163 lines
4.9 KiB
Python
|
|
"""
|
||
|
|
Per-network hierarchical permission system for Wormhole.
|
||
|
|
|
||
|
|
Roles are scoped to individual networks — a user can be Admin in one
|
||
|
|
network and have no role in another. Bot owners (Red's built-in
|
||
|
|
``bot.is_owner()``) bypass all checks globally.
|
||
|
|
|
||
|
|
Hierarchy (highest → lowest):
|
||
|
|
Owner → Admin → Moderator → Helper → Member
|
||
|
|
|
||
|
|
Higher roles can assign any role below them.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import enum
|
||
|
|
import functools
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
import discord
|
||
|
|
from redbot.core import commands
|
||
|
|
|
||
|
|
|
||
|
|
class Role(enum.IntEnum):
|
||
|
|
"""Network permission tiers. Higher value = more power."""
|
||
|
|
|
||
|
|
MEMBER = 0
|
||
|
|
HELPER = 1
|
||
|
|
MODERATOR = 2
|
||
|
|
ADMIN = 3
|
||
|
|
OWNER = 4
|
||
|
|
|
||
|
|
|
||
|
|
_ROLE_NAMES = {
|
||
|
|
Role.OWNER: "Owner",
|
||
|
|
Role.ADMIN: "Admin",
|
||
|
|
Role.MODERATOR: "Moderator",
|
||
|
|
Role.HELPER: "Helper",
|
||
|
|
Role.MEMBER: "Member",
|
||
|
|
}
|
||
|
|
|
||
|
|
_ROLE_LOOKUP = {v.lower(): k for k, v in _ROLE_NAMES.items()}
|
||
|
|
|
||
|
|
|
||
|
|
def role_name(role: Role) -> str:
|
||
|
|
"""Human-friendly name for a role."""
|
||
|
|
return _ROLE_NAMES.get(role, "Unknown")
|
||
|
|
|
||
|
|
|
||
|
|
def role_from_str(name: str) -> Optional[Role]:
|
||
|
|
"""Parse a role name (case-insensitive). Returns ``None`` on failure."""
|
||
|
|
return _ROLE_LOOKUP.get(name.strip().lower())
|
||
|
|
|
||
|
|
|
||
|
|
def _get_user_role(net_data: dict, user_id: int) -> Role:
|
||
|
|
"""Determine a user's role in a network from its config dict."""
|
||
|
|
if user_id == net_data.get("owner_id"):
|
||
|
|
return Role.OWNER
|
||
|
|
staff = net_data.get("staff", {})
|
||
|
|
uid_str = str(user_id)
|
||
|
|
if uid_str in staff:
|
||
|
|
return Role(staff[uid_str])
|
||
|
|
# Legacy flat list migration: treat old staff_ids entries as Admin
|
||
|
|
if user_id in net_data.get("staff_ids", []):
|
||
|
|
return Role.ADMIN
|
||
|
|
return Role.MEMBER
|
||
|
|
|
||
|
|
|
||
|
|
def has_role(net_data: dict, user_id: int, minimum: Role) -> bool:
|
||
|
|
"""Check whether *user_id* meets *minimum* role in this network."""
|
||
|
|
return _get_user_role(net_data, user_id) >= minimum
|
||
|
|
|
||
|
|
|
||
|
|
def get_role(net_data: dict, user_id: int) -> Role:
|
||
|
|
"""Return the user's role in a network."""
|
||
|
|
return _get_user_role(net_data, user_id)
|
||
|
|
|
||
|
|
|
||
|
|
def can_assign(actor_role: Role, target_role: Role) -> bool:
|
||
|
|
"""Whether *actor_role* is allowed to assign/revoke *target_role*."""
|
||
|
|
return actor_role > target_role
|
||
|
|
|
||
|
|
|
||
|
|
def set_staff_role(net_data: dict, user_id: int, role: Role) -> None:
|
||
|
|
"""Assign *role* to *user_id* in the network config (mutates in place).
|
||
|
|
|
||
|
|
Setting ``Role.MEMBER`` removes the user from the staff dict.
|
||
|
|
"""
|
||
|
|
staff = net_data.setdefault("staff", {})
|
||
|
|
uid_str = str(user_id)
|
||
|
|
if role <= Role.MEMBER:
|
||
|
|
staff.pop(uid_str, None)
|
||
|
|
else:
|
||
|
|
staff[uid_str] = int(role)
|
||
|
|
|
||
|
|
|
||
|
|
def remove_staff(net_data: dict, user_id: int) -> None:
|
||
|
|
"""Remove a user from network staff entirely."""
|
||
|
|
set_staff_role(net_data, user_id, Role.MEMBER)
|
||
|
|
|
||
|
|
|
||
|
|
def list_staff(net_data: dict) -> dict[int, Role]:
|
||
|
|
"""Return ``{user_id: Role}`` for all staff (including owner)."""
|
||
|
|
result: dict[int, Role] = {}
|
||
|
|
owner = net_data.get("owner_id")
|
||
|
|
if owner:
|
||
|
|
result[owner] = Role.OWNER
|
||
|
|
for uid_str, level in net_data.get("staff", {}).items():
|
||
|
|
try:
|
||
|
|
result[int(uid_str)] = Role(level)
|
||
|
|
except (ValueError, KeyError):
|
||
|
|
pass
|
||
|
|
# Legacy migration
|
||
|
|
for uid in net_data.get("staff_ids", []):
|
||
|
|
if uid not in result:
|
||
|
|
result[uid] = Role.ADMIN
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
# ── Decorator for commands ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def requires_role(minimum: Role):
|
||
|
|
"""Decorator factory — the command must supply *name* as first arg.
|
||
|
|
|
||
|
|
Usage inside a cog::
|
||
|
|
|
||
|
|
@commands.command()
|
||
|
|
@requires_role(Role.MODERATOR)
|
||
|
|
async def wh_mod_ban(self, ctx, name: str, user: discord.User):
|
||
|
|
...
|
||
|
|
|
||
|
|
The decorator fetches the network data for *name*, checks the
|
||
|
|
invoker's role, and short-circuits with an error embed if they
|
||
|
|
lack permission. Bot owners always pass.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def decorator(func):
|
||
|
|
@functools.wraps(func)
|
||
|
|
async def wrapper(self, ctx: commands.Context, name: str, *args, **kwargs):
|
||
|
|
# Bot owner bypasses everything
|
||
|
|
if await self.bot.is_owner(ctx.author):
|
||
|
|
return await func(self, ctx, name, *args, **kwargs)
|
||
|
|
# Fetch network
|
||
|
|
nd = await self._net(name)
|
||
|
|
if nd is None:
|
||
|
|
from ..utils import err_embed
|
||
|
|
await ctx.send(embed=err_embed(f"Network `{name}` not found."))
|
||
|
|
return
|
||
|
|
if not has_role(nd, ctx.author.id, minimum):
|
||
|
|
from ..utils import err_embed
|
||
|
|
await ctx.send(
|
||
|
|
embed=err_embed(
|
||
|
|
f"You need at least **{role_name(minimum)}** "
|
||
|
|
f"role in `{name}` to use this command."
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return
|
||
|
|
return await func(self, ctx, name, *args, **kwargs)
|
||
|
|
|
||
|
|
return wrapper
|
||
|
|
|
||
|
|
return decorator
|