kroxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kroxy/__init__.py +13 -0
- kroxy/discord/__init__.py +22 -0
- kroxy/discord/antinuke.py +146 -0
- kroxy/discord/api.py +128 -0
- kroxy/discord/checkers.py +159 -0
- kroxy/discord/commands.py +187 -0
- kroxy/discord/giveaway.py +226 -0
- kroxy/discord/music.py +236 -0
- kroxy/discord/utils.py +190 -0
- kroxy/website/__init__.py +5 -0
- kroxy-0.1.0.dist-info/METADATA +147 -0
- kroxy-0.1.0.dist-info/RECORD +15 -0
- kroxy-0.1.0.dist-info/WHEEL +5 -0
- kroxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- kroxy-0.1.0.dist-info/top_level.txt +1 -0
kroxy/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord - Professional Discord utilities and helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from kroxy.discord.api import DiscordAPI
|
|
6
|
+
from kroxy.discord.commands import SlashCommand, PrefixCommand
|
|
7
|
+
from kroxy.discord.utils import Utils
|
|
8
|
+
from kroxy.discord.antinuke import AntiNuke
|
|
9
|
+
from kroxy.discord.checkers import Checkers
|
|
10
|
+
from kroxy.discord.giveaway import Giveaway
|
|
11
|
+
from kroxy.discord.music import MusicPlayer
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DiscordAPI",
|
|
15
|
+
"SlashCommand",
|
|
16
|
+
"PrefixCommand",
|
|
17
|
+
"Utils",
|
|
18
|
+
"AntiNuke",
|
|
19
|
+
"Checkers",
|
|
20
|
+
"Giveaway",
|
|
21
|
+
"MusicPlayer",
|
|
22
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.antinuke - Anti-nuke protection system for Discord servers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Dict, List, Optional, Callable, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ActionLog:
|
|
12
|
+
"""Tracks recent actions per user for rate-limit detection."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, threshold: int, window: int):
|
|
15
|
+
self.threshold = threshold
|
|
16
|
+
self.window = window # seconds
|
|
17
|
+
self._log: Dict[int, List[float]] = defaultdict(list)
|
|
18
|
+
|
|
19
|
+
def record(self, user_id: int) -> int:
|
|
20
|
+
"""Record an action and return the count within the window."""
|
|
21
|
+
now = time.time()
|
|
22
|
+
self._log[user_id] = [t for t in self._log[user_id] if now - t < self.window]
|
|
23
|
+
self._log[user_id].append(now)
|
|
24
|
+
return len(self._log[user_id])
|
|
25
|
+
|
|
26
|
+
def exceeded(self, user_id: int) -> bool:
|
|
27
|
+
"""Return True if the user has exceeded the threshold."""
|
|
28
|
+
return self.record(user_id) >= self.threshold
|
|
29
|
+
|
|
30
|
+
def reset(self, user_id: int):
|
|
31
|
+
self._log.pop(user_id, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AntiNuke:
|
|
35
|
+
"""
|
|
36
|
+
Anti-nuke system that monitors destructive actions and auto-punishes abusers.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
antinuke = AntiNuke(bot, whitelist=[owner_id])
|
|
40
|
+
antinuke.on_trigger = my_punishment_callback
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
DEFAULT_LIMITS = {
|
|
44
|
+
"ban": (3, 10), # 3 bans in 10s
|
|
45
|
+
"kick": (3, 10),
|
|
46
|
+
"channel_delete": (3, 10),
|
|
47
|
+
"channel_create": (5, 10),
|
|
48
|
+
"role_delete": (3, 10),
|
|
49
|
+
"role_create": (5, 10),
|
|
50
|
+
"webhook_create": (3, 10),
|
|
51
|
+
"webhook_delete": (3, 10),
|
|
52
|
+
"invite_delete": (5, 10),
|
|
53
|
+
"mass_mention": (5, 5),
|
|
54
|
+
"bot_add": (2, 30),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(self, whitelist: List[int] = None, limits: Dict = None):
|
|
58
|
+
self.whitelist: List[int] = whitelist or []
|
|
59
|
+
limits = limits or {}
|
|
60
|
+
merged = {**self.DEFAULT_LIMITS, **limits}
|
|
61
|
+
self._trackers: Dict[str, ActionLog] = {
|
|
62
|
+
action: ActionLog(threshold, window)
|
|
63
|
+
for action, (threshold, window) in merged.items()
|
|
64
|
+
}
|
|
65
|
+
self._punished: set = set()
|
|
66
|
+
self.on_trigger: Optional[Callable] = None
|
|
67
|
+
self.punishment: str = "ban" # "ban" | "kick" | "strip_roles"
|
|
68
|
+
|
|
69
|
+
def is_whitelisted(self, user_id: int) -> bool:
|
|
70
|
+
return user_id in self.whitelist
|
|
71
|
+
|
|
72
|
+
def add_whitelist(self, user_id: int):
|
|
73
|
+
if user_id not in self.whitelist:
|
|
74
|
+
self.whitelist.append(user_id)
|
|
75
|
+
|
|
76
|
+
def remove_whitelist(self, user_id: int):
|
|
77
|
+
self.whitelist = [u for u in self.whitelist if u != user_id]
|
|
78
|
+
|
|
79
|
+
async def check(self, action: str, user_id: int, guild: Any = None) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Check an action for a user. Returns True if limit exceeded.
|
|
82
|
+
Calls self.on_trigger(action, user_id, guild) if set.
|
|
83
|
+
"""
|
|
84
|
+
if self.is_whitelisted(user_id):
|
|
85
|
+
return False
|
|
86
|
+
if user_id in self._punished:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
tracker = self._trackers.get(action)
|
|
90
|
+
if tracker is None:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
if tracker.exceeded(user_id):
|
|
94
|
+
self._punished.add(user_id)
|
|
95
|
+
if self.on_trigger:
|
|
96
|
+
try:
|
|
97
|
+
await self.on_trigger(action=action, user_id=user_id, guild=guild)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def reset_user(self, user_id: int):
|
|
104
|
+
"""Clear all action logs and punishment flag for a user."""
|
|
105
|
+
for tracker in self._trackers.values():
|
|
106
|
+
tracker.reset(user_id)
|
|
107
|
+
self._punished.discard(user_id)
|
|
108
|
+
|
|
109
|
+
def get_stats(self) -> Dict:
|
|
110
|
+
"""Return current limits configuration."""
|
|
111
|
+
return {
|
|
112
|
+
action: {"threshold": t.threshold, "window": t.window}
|
|
113
|
+
for action, t in self._trackers.items()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# ── Convenience event handlers (wire these to your event system) ──────────
|
|
117
|
+
|
|
118
|
+
async def on_member_ban(self, user_id: int, guild: Any = None):
|
|
119
|
+
return await self.check("ban", user_id, guild)
|
|
120
|
+
|
|
121
|
+
async def on_member_kick(self, user_id: int, guild: Any = None):
|
|
122
|
+
return await self.check("kick", user_id, guild)
|
|
123
|
+
|
|
124
|
+
async def on_channel_delete(self, user_id: int, guild: Any = None):
|
|
125
|
+
return await self.check("channel_delete", user_id, guild)
|
|
126
|
+
|
|
127
|
+
async def on_channel_create(self, user_id: int, guild: Any = None):
|
|
128
|
+
return await self.check("channel_create", user_id, guild)
|
|
129
|
+
|
|
130
|
+
async def on_role_delete(self, user_id: int, guild: Any = None):
|
|
131
|
+
return await self.check("role_delete", user_id, guild)
|
|
132
|
+
|
|
133
|
+
async def on_role_create(self, user_id: int, guild: Any = None):
|
|
134
|
+
return await self.check("role_create", user_id, guild)
|
|
135
|
+
|
|
136
|
+
async def on_webhook_create(self, user_id: int, guild: Any = None):
|
|
137
|
+
return await self.check("webhook_create", user_id, guild)
|
|
138
|
+
|
|
139
|
+
async def on_webhook_delete(self, user_id: int, guild: Any = None):
|
|
140
|
+
return await self.check("webhook_delete", user_id, guild)
|
|
141
|
+
|
|
142
|
+
async def on_bot_add(self, user_id: int, guild: Any = None):
|
|
143
|
+
return await self.check("bot_add", user_id, guild)
|
|
144
|
+
|
|
145
|
+
async def on_mass_mention(self, user_id: int, guild: Any = None):
|
|
146
|
+
return await self.check("mass_mention", user_id, guild)
|
kroxy/discord/api.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.api - Discord REST API wrapper helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Optional, Any, Dict
|
|
8
|
+
|
|
9
|
+
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiscordAPI:
|
|
13
|
+
"""Async Discord REST API client."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, token: str, bot: bool = True):
|
|
16
|
+
prefix = "Bot" if bot else "Bearer"
|
|
17
|
+
self.headers = {
|
|
18
|
+
"Authorization": f"{prefix} {token}",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
}
|
|
21
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
22
|
+
|
|
23
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
24
|
+
if self._session is None or self._session.closed:
|
|
25
|
+
self._session = aiohttp.ClientSession(headers=self.headers)
|
|
26
|
+
return self._session
|
|
27
|
+
|
|
28
|
+
async def close(self):
|
|
29
|
+
"""Close the underlying HTTP session."""
|
|
30
|
+
if self._session and not self._session.closed:
|
|
31
|
+
await self._session.close()
|
|
32
|
+
|
|
33
|
+
async def request(self, method: str, endpoint: str, **kwargs) -> Any:
|
|
34
|
+
"""Make a raw API request."""
|
|
35
|
+
session = await self._get_session()
|
|
36
|
+
url = f"{DISCORD_API_BASE}{endpoint}"
|
|
37
|
+
async with session.request(method, url, **kwargs) as resp:
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
return await resp.json()
|
|
40
|
+
|
|
41
|
+
async def get_guild(self, guild_id: int) -> Dict:
|
|
42
|
+
"""Fetch a guild by ID."""
|
|
43
|
+
return await self.request("GET", f"/guilds/{guild_id}")
|
|
44
|
+
|
|
45
|
+
async def get_channel(self, channel_id: int) -> Dict:
|
|
46
|
+
"""Fetch a channel by ID."""
|
|
47
|
+
return await self.request("GET", f"/channels/{channel_id}")
|
|
48
|
+
|
|
49
|
+
async def get_user(self, user_id: int) -> Dict:
|
|
50
|
+
"""Fetch a user by ID."""
|
|
51
|
+
return await self.request("GET", f"/users/{user_id}")
|
|
52
|
+
|
|
53
|
+
async def send_message(self, channel_id: int, content: str = None,
|
|
54
|
+
embed: Dict = None, components: list = None) -> Dict:
|
|
55
|
+
"""Send a message to a channel."""
|
|
56
|
+
payload: Dict[str, Any] = {}
|
|
57
|
+
if content:
|
|
58
|
+
payload["content"] = content
|
|
59
|
+
if embed:
|
|
60
|
+
payload["embeds"] = [embed]
|
|
61
|
+
if components:
|
|
62
|
+
payload["components"] = components
|
|
63
|
+
return await self.request("POST", f"/channels/{channel_id}/messages", json=payload)
|
|
64
|
+
|
|
65
|
+
async def delete_message(self, channel_id: int, message_id: int) -> None:
|
|
66
|
+
"""Delete a message."""
|
|
67
|
+
await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
|
|
68
|
+
|
|
69
|
+
async def ban_member(self, guild_id: int, user_id: int,
|
|
70
|
+
reason: str = None, delete_message_days: int = 0) -> None:
|
|
71
|
+
"""Ban a member from a guild."""
|
|
72
|
+
payload = {"delete_message_days": delete_message_days}
|
|
73
|
+
headers = {}
|
|
74
|
+
if reason:
|
|
75
|
+
headers["X-Audit-Log-Reason"] = reason
|
|
76
|
+
await self.request("PUT", f"/guilds/{guild_id}/bans/{user_id}",
|
|
77
|
+
json=payload, headers=headers)
|
|
78
|
+
|
|
79
|
+
async def unban_member(self, guild_id: int, user_id: int) -> None:
|
|
80
|
+
"""Unban a member."""
|
|
81
|
+
await self.request("DELETE", f"/guilds/{guild_id}/bans/{user_id}")
|
|
82
|
+
|
|
83
|
+
async def kick_member(self, guild_id: int, user_id: int, reason: str = None) -> None:
|
|
84
|
+
"""Kick a member from a guild."""
|
|
85
|
+
headers = {}
|
|
86
|
+
if reason:
|
|
87
|
+
headers["X-Audit-Log-Reason"] = reason
|
|
88
|
+
await self.request("DELETE", f"/guilds/{guild_id}/members/{user_id}",
|
|
89
|
+
headers=headers)
|
|
90
|
+
|
|
91
|
+
async def add_role(self, guild_id: int, user_id: int, role_id: int) -> None:
|
|
92
|
+
"""Add a role to a member."""
|
|
93
|
+
await self.request("PUT", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
|
|
94
|
+
|
|
95
|
+
async def remove_role(self, guild_id: int, user_id: int, role_id: int) -> None:
|
|
96
|
+
"""Remove a role from a member."""
|
|
97
|
+
await self.request("DELETE", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
|
|
98
|
+
|
|
99
|
+
async def create_channel(self, guild_id: int, name: str,
|
|
100
|
+
channel_type: int = 0, **kwargs) -> Dict:
|
|
101
|
+
"""Create a guild channel."""
|
|
102
|
+
payload = {"name": name, "type": channel_type, **kwargs}
|
|
103
|
+
return await self.request("POST", f"/guilds/{guild_id}/channels", json=payload)
|
|
104
|
+
|
|
105
|
+
async def delete_channel(self, channel_id: int) -> Dict:
|
|
106
|
+
"""Delete a channel."""
|
|
107
|
+
return await self.request("DELETE", f"/channels/{channel_id}")
|
|
108
|
+
|
|
109
|
+
async def get_audit_logs(self, guild_id: int, limit: int = 50) -> Dict:
|
|
110
|
+
"""Fetch guild audit logs."""
|
|
111
|
+
return await self.request("GET", f"/guilds/{guild_id}/audit-logs",
|
|
112
|
+
params={"limit": limit})
|
|
113
|
+
|
|
114
|
+
async def get_invites(self, guild_id: int) -> list:
|
|
115
|
+
"""Get all guild invites."""
|
|
116
|
+
return await self.request("GET", f"/guilds/{guild_id}/invites")
|
|
117
|
+
|
|
118
|
+
async def delete_invite(self, invite_code: str) -> None:
|
|
119
|
+
"""Delete an invite by code."""
|
|
120
|
+
await self.request("DELETE", f"/invites/{invite_code}")
|
|
121
|
+
|
|
122
|
+
async def get_webhooks(self, guild_id: int) -> list:
|
|
123
|
+
"""Get all webhooks in a guild."""
|
|
124
|
+
return await self.request("GET", f"/guilds/{guild_id}/webhooks")
|
|
125
|
+
|
|
126
|
+
async def delete_webhook(self, webhook_id: int) -> None:
|
|
127
|
+
"""Delete a webhook."""
|
|
128
|
+
await self.request("DELETE", f"/webhooks/{webhook_id}")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.checkers - Permission and role check utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Union, Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CheckFailed(Exception):
|
|
9
|
+
"""Raised when a permission/role check fails."""
|
|
10
|
+
def __init__(self, message: str):
|
|
11
|
+
self.message = message
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Checkers:
|
|
16
|
+
"""
|
|
17
|
+
Collection of Discord permission and role checks.
|
|
18
|
+
Each method raises CheckFailed on failure or returns True on success.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# ── Permission checks ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def has_permission(member_permissions: int, permission_flag: int,
|
|
25
|
+
admin_bypass: bool = True) -> bool:
|
|
26
|
+
"""Check if a permissions bitfield includes a flag."""
|
|
27
|
+
admin_flag = 1 << 3
|
|
28
|
+
if admin_bypass and (member_permissions & admin_flag):
|
|
29
|
+
return True
|
|
30
|
+
return bool(member_permissions & permission_flag)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def require_permission(member_permissions: int, permission_flag: int,
|
|
34
|
+
permission_name: str = "required permission") -> bool:
|
|
35
|
+
if not Checkers.has_permission(member_permissions, permission_flag):
|
|
36
|
+
raise CheckFailed(f"You need the `{permission_name}` permission.")
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def require_admin(member_permissions: int) -> bool:
|
|
41
|
+
admin_flag = 1 << 3
|
|
42
|
+
if not (member_permissions & admin_flag):
|
|
43
|
+
raise CheckFailed("You need the `Administrator` permission.")
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def require_manage_guild(member_permissions: int) -> bool:
|
|
48
|
+
return Checkers.require_permission(member_permissions, 1 << 5, "Manage Server")
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def require_manage_roles(member_permissions: int) -> bool:
|
|
52
|
+
return Checkers.require_permission(member_permissions, 1 << 28, "Manage Roles")
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def require_ban_members(member_permissions: int) -> bool:
|
|
56
|
+
return Checkers.require_permission(member_permissions, 1 << 2, "Ban Members")
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def require_kick_members(member_permissions: int) -> bool:
|
|
60
|
+
return Checkers.require_permission(member_permissions, 1 << 1, "Kick Members")
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def require_manage_channels(member_permissions: int) -> bool:
|
|
64
|
+
return Checkers.require_permission(member_permissions, 1 << 4, "Manage Channels")
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def require_manage_messages(member_permissions: int) -> bool:
|
|
68
|
+
return Checkers.require_permission(member_permissions, 1 << 13, "Manage Messages")
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def require_manage_webhooks(member_permissions: int) -> bool:
|
|
72
|
+
return Checkers.require_permission(member_permissions, 1 << 29, "Manage Webhooks")
|
|
73
|
+
|
|
74
|
+
# ── Role checks ───────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def has_role(member_role_ids: List[int], role_id: int) -> bool:
|
|
78
|
+
"""Check if a member has a specific role."""
|
|
79
|
+
return role_id in member_role_ids
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def has_any_role(member_role_ids: List[int], role_ids: List[int]) -> bool:
|
|
83
|
+
"""Check if a member has at least one of the given roles."""
|
|
84
|
+
return bool(set(member_role_ids) & set(role_ids))
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def has_all_roles(member_role_ids: List[int], role_ids: List[int]) -> bool:
|
|
88
|
+
"""Check if a member has all of the given roles."""
|
|
89
|
+
return set(role_ids).issubset(set(member_role_ids))
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def require_role(member_role_ids: List[int], role_id: int,
|
|
93
|
+
role_name: str = "a required role") -> bool:
|
|
94
|
+
if not Checkers.has_role(member_role_ids, role_id):
|
|
95
|
+
raise CheckFailed(f"You need the **{role_name}** role.")
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def require_any_role(member_role_ids: List[int], role_ids: List[int]) -> bool:
|
|
100
|
+
if not Checkers.has_any_role(member_role_ids, role_ids):
|
|
101
|
+
raise CheckFailed("You don't have any of the required roles.")
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
# ── Hierarchy checks ──────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def check_hierarchy(executor_top_role_pos: int, target_top_role_pos: int,
|
|
108
|
+
bot_top_role_pos: int) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Ensure the executor and bot are higher than the target in the role hierarchy.
|
|
111
|
+
Raises CheckFailed on violation.
|
|
112
|
+
"""
|
|
113
|
+
if target_top_role_pos >= executor_top_role_pos:
|
|
114
|
+
raise CheckFailed("You cannot perform this action on someone with an equal or higher role.")
|
|
115
|
+
if target_top_role_pos >= bot_top_role_pos:
|
|
116
|
+
raise CheckFailed("I cannot perform this action on someone with a role equal to or higher than mine.")
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# ── Guild-level checks ────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def is_guild_owner(user_id: int, guild_owner_id: int) -> bool:
|
|
123
|
+
return user_id == guild_owner_id
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def require_guild_owner(user_id: int, guild_owner_id: int) -> bool:
|
|
127
|
+
if not Checkers.is_guild_owner(user_id, guild_owner_id):
|
|
128
|
+
raise CheckFailed("Only the server owner can use this command.")
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def is_bot(is_bot_flag: bool) -> bool:
|
|
133
|
+
return is_bot_flag
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def block_bots(is_bot_flag: bool) -> bool:
|
|
137
|
+
if is_bot_flag:
|
|
138
|
+
raise CheckFailed("Bots cannot use this command.")
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
# ── Channel checks ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def require_guild_only(guild_id: Optional[int]) -> bool:
|
|
145
|
+
if guild_id is None:
|
|
146
|
+
raise CheckFailed("This command can only be used in a server.")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def require_dm_only(guild_id: Optional[int]) -> bool:
|
|
151
|
+
if guild_id is not None:
|
|
152
|
+
raise CheckFailed("This command can only be used in DMs.")
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def require_nsfw(channel_is_nsfw: bool) -> bool:
|
|
157
|
+
if not channel_is_nsfw:
|
|
158
|
+
raise CheckFailed("This command can only be used in NSFW channels.")
|
|
159
|
+
return True
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.commands - Slash and prefix command builders.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional, List, Dict, Any
|
|
6
|
+
import functools
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Option:
|
|
10
|
+
"""Represents a slash command option."""
|
|
11
|
+
|
|
12
|
+
TYPES = {
|
|
13
|
+
"sub_command": 1,
|
|
14
|
+
"sub_command_group": 2,
|
|
15
|
+
"string": 3,
|
|
16
|
+
"integer": 4,
|
|
17
|
+
"boolean": 5,
|
|
18
|
+
"user": 6,
|
|
19
|
+
"channel": 7,
|
|
20
|
+
"role": 8,
|
|
21
|
+
"mentionable": 9,
|
|
22
|
+
"number": 10,
|
|
23
|
+
"attachment": 11,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, name: str, description: str, option_type: str = "string",
|
|
27
|
+
required: bool = False, choices: List[Dict] = None):
|
|
28
|
+
self.name = name
|
|
29
|
+
self.description = description
|
|
30
|
+
self.type = self.TYPES.get(option_type, 3)
|
|
31
|
+
self.required = required
|
|
32
|
+
self.choices = choices or []
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> Dict:
|
|
35
|
+
data = {
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"description": self.description,
|
|
38
|
+
"type": self.type,
|
|
39
|
+
"required": self.required,
|
|
40
|
+
}
|
|
41
|
+
if self.choices:
|
|
42
|
+
data["choices"] = self.choices
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SlashCommand:
|
|
47
|
+
"""Slash command builder and decorator."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, name: str, description: str,
|
|
50
|
+
options: List[Option] = None,
|
|
51
|
+
default_member_permissions: Optional[str] = None,
|
|
52
|
+
dm_permission: bool = True):
|
|
53
|
+
self.name = name
|
|
54
|
+
self.description = description
|
|
55
|
+
self.options = options or []
|
|
56
|
+
self.default_member_permissions = default_member_permissions
|
|
57
|
+
self.dm_permission = dm_permission
|
|
58
|
+
self._callback: Optional[Callable] = None
|
|
59
|
+
|
|
60
|
+
def __call__(self, func: Callable):
|
|
61
|
+
self._callback = func
|
|
62
|
+
functools.update_wrapper(self, func)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
async def invoke(self, interaction: Any, **kwargs):
|
|
66
|
+
"""Invoke the command callback."""
|
|
67
|
+
if self._callback is None:
|
|
68
|
+
raise RuntimeError(f"No callback set for slash command '{self.name}'")
|
|
69
|
+
return await self._callback(interaction, **kwargs)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> Dict:
|
|
72
|
+
data = {
|
|
73
|
+
"name": self.name,
|
|
74
|
+
"description": self.description,
|
|
75
|
+
"options": [opt.to_dict() for opt in self.options],
|
|
76
|
+
"dm_permission": self.dm_permission,
|
|
77
|
+
}
|
|
78
|
+
if self.default_member_permissions is not None:
|
|
79
|
+
data["default_member_permissions"] = self.default_member_permissions
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def decorator(name: str, description: str, options: List[Option] = None,
|
|
84
|
+
permissions: str = None):
|
|
85
|
+
"""Decorator factory for slash commands."""
|
|
86
|
+
def wrapper(func: Callable) -> SlashCommand:
|
|
87
|
+
cmd = SlashCommand(
|
|
88
|
+
name=name,
|
|
89
|
+
description=description,
|
|
90
|
+
options=options,
|
|
91
|
+
default_member_permissions=permissions,
|
|
92
|
+
)
|
|
93
|
+
cmd._callback = func
|
|
94
|
+
return cmd
|
|
95
|
+
return wrapper
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PrefixCommand:
|
|
99
|
+
"""Prefix (text-based) command builder and decorator."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str, aliases: List[str] = None,
|
|
102
|
+
description: str = "", cooldown: int = 0,
|
|
103
|
+
required_permissions: List[str] = None):
|
|
104
|
+
self.name = name
|
|
105
|
+
self.aliases = aliases or []
|
|
106
|
+
self.description = description
|
|
107
|
+
self.cooldown = cooldown
|
|
108
|
+
self.required_permissions = required_permissions or []
|
|
109
|
+
self._callback: Optional[Callable] = None
|
|
110
|
+
self._cooldowns: Dict[int, float] = {}
|
|
111
|
+
|
|
112
|
+
def __call__(self, func: Callable):
|
|
113
|
+
self._callback = func
|
|
114
|
+
functools.update_wrapper(self, func)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
async def invoke(self, ctx: Any, *args, **kwargs):
|
|
118
|
+
"""Invoke the command with cooldown check."""
|
|
119
|
+
import time
|
|
120
|
+
if self.cooldown > 0:
|
|
121
|
+
user_id = getattr(ctx.author, "id", 0)
|
|
122
|
+
last_used = self._cooldowns.get(user_id, 0)
|
|
123
|
+
now = time.time()
|
|
124
|
+
if now - last_used < self.cooldown:
|
|
125
|
+
remaining = self.cooldown - (now - last_used)
|
|
126
|
+
raise CooldownError(f"Command on cooldown. Try again in {remaining:.1f}s.")
|
|
127
|
+
self._cooldowns[user_id] = now
|
|
128
|
+
|
|
129
|
+
if self._callback is None:
|
|
130
|
+
raise RuntimeError(f"No callback set for prefix command '{self.name}'")
|
|
131
|
+
return await self._callback(ctx, *args, **kwargs)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def decorator(name: str, aliases: List[str] = None,
|
|
135
|
+
description: str = "", cooldown: int = 0,
|
|
136
|
+
permissions: List[str] = None):
|
|
137
|
+
"""Decorator factory for prefix commands."""
|
|
138
|
+
def wrapper(func: Callable) -> PrefixCommand:
|
|
139
|
+
cmd = PrefixCommand(
|
|
140
|
+
name=name,
|
|
141
|
+
aliases=aliases,
|
|
142
|
+
description=description,
|
|
143
|
+
cooldown=cooldown,
|
|
144
|
+
required_permissions=permissions,
|
|
145
|
+
)
|
|
146
|
+
cmd._callback = func
|
|
147
|
+
return cmd
|
|
148
|
+
return wrapper
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class CooldownError(Exception):
|
|
152
|
+
"""Raised when a command is on cooldown."""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CommandRegistry:
|
|
157
|
+
"""Registry to manage all slash and prefix commands."""
|
|
158
|
+
|
|
159
|
+
def __init__(self):
|
|
160
|
+
self._slash: Dict[str, SlashCommand] = {}
|
|
161
|
+
self._prefix: Dict[str, PrefixCommand] = {}
|
|
162
|
+
|
|
163
|
+
def add_slash(self, command: SlashCommand):
|
|
164
|
+
self._slash[command.name] = command
|
|
165
|
+
|
|
166
|
+
def add_prefix(self, command: PrefixCommand):
|
|
167
|
+
self._prefix[command.name] = command
|
|
168
|
+
for alias in command.aliases:
|
|
169
|
+
self._prefix[alias] = command
|
|
170
|
+
|
|
171
|
+
def get_slash(self, name: str) -> Optional[SlashCommand]:
|
|
172
|
+
return self._slash.get(name)
|
|
173
|
+
|
|
174
|
+
def get_prefix(self, name: str) -> Optional[PrefixCommand]:
|
|
175
|
+
return self._prefix.get(name)
|
|
176
|
+
|
|
177
|
+
def all_slash(self) -> List[SlashCommand]:
|
|
178
|
+
return list(self._slash.values())
|
|
179
|
+
|
|
180
|
+
def all_prefix(self) -> List[PrefixCommand]:
|
|
181
|
+
seen = set()
|
|
182
|
+
result = []
|
|
183
|
+
for cmd in self._prefix.values():
|
|
184
|
+
if cmd.name not in seen:
|
|
185
|
+
seen.add(cmd.name)
|
|
186
|
+
result.append(cmd)
|
|
187
|
+
return result
|