EveCogs/wormhole/models/permissions.py
Viktor AI 0b028e7bb9 refactor: Wormhole v4.0.0 — full modular architecture with hierarchical staff
BREAKING: Complete cog restructure from monolithic wormhole.py into modular architecture.

## Architecture
- models/permissions.py — Role IntEnum (MEMBER→OWNER), requires_role() decorator
- models/config.py — DEFAULT_NETWORK/DEFAULT_GLOBAL with 80+ config keys
- models/message_map.py — MessageMap class (extracted from _MessageMap)
- services/emoji.py — Foreign emoji resolution and relay
- ui/views.py — Reply jump button view
- ui/modals.py — Report modal
- core.py — Main Wormhole class, helpers, background loops, context menus
- commands/ — 13 command modules as mixin classes
- listeners/ — relay pipeline, edit/delete/reaction sync, typing/pin sync

## Staff System (new)
Per-network hierarchical roles replacing flat owner/staff:
  Bot Owner (Red built-in) — global override
  └─ Network Owner → Admin → Moderator → Helper → Member
  Each role assigned per-network. Higher roles can assign lower.
  Legacy staff_ids auto-migrated to ADMIN role.

## Enhanced Moderation
- Server-level ban/mute (not just user-level)
- Allowlist mode (only whitelisted servers can send)
- Mod edit and nuke (delete N messages by user)
- Anti-raid detector with auto-freeze
- Expanded automod: anti-zalgo, anti-emote-spam, anti-newline-spam

## Commands (100+)
network, settings, staff, moderation, filters, social, dm, mentions,
tos, reports, bridge, advanced (polls/afk/auto-responses/scheduled/
analytics/quiet-hours/colours/invites/announcements/blackout/portal),
debug (health/trace/testsend/relaydebug/backup/restore/version)

## Files
- 30 Python files, 5,274 lines total (was 4,782 in monolith)
- Original wormhole.py preserved for reference
- utils.py unchanged (all helpers reused)
2026-05-14 19:07:49 +00:00

162 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