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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.giveaway - Full-featured Discord giveaway system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Dict, List, Optional, Callable, Set, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GiveawayEntry:
|
|
15
|
+
user_id: int
|
|
16
|
+
entries: int = 1
|
|
17
|
+
joined_at: float = field(default_factory=time.time)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Giveaway:
|
|
22
|
+
"""Represents a single giveaway."""
|
|
23
|
+
|
|
24
|
+
prize: str
|
|
25
|
+
host_id: int
|
|
26
|
+
channel_id: int
|
|
27
|
+
guild_id: int
|
|
28
|
+
winner_count: int = 1
|
|
29
|
+
ends_at: float = 0.0
|
|
30
|
+
giveaway_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
|
|
31
|
+
message_id: Optional[int] = None
|
|
32
|
+
required_role_id: Optional[int] = None
|
|
33
|
+
bonus_roles: Dict[int, int] = field(default_factory=dict)
|
|
34
|
+
entries: Dict[int, GiveawayEntry] = field(default_factory=dict)
|
|
35
|
+
ended: bool = False
|
|
36
|
+
winners: List[int] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def add_entry(self, user_id: int, role_ids: List[int] = None) -> int:
|
|
39
|
+
"""Add or update an entry. Returns total entries for this user."""
|
|
40
|
+
bonus = 0
|
|
41
|
+
if role_ids and self.bonus_roles:
|
|
42
|
+
bonus = sum(
|
|
43
|
+
self.bonus_roles[r] for r in role_ids if r in self.bonus_roles
|
|
44
|
+
)
|
|
45
|
+
total = 1 + bonus
|
|
46
|
+
|
|
47
|
+
if user_id in self.entries:
|
|
48
|
+
self.entries[user_id].entries = total
|
|
49
|
+
else:
|
|
50
|
+
self.entries[user_id] = GiveawayEntry(user_id=user_id, entries=total)
|
|
51
|
+
return total
|
|
52
|
+
|
|
53
|
+
def remove_entry(self, user_id: int) -> bool:
|
|
54
|
+
"""Remove a user's entry. Returns True if removed."""
|
|
55
|
+
if user_id in self.entries:
|
|
56
|
+
del self.entries[user_id]
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def pick_winners(self) -> List[int]:
|
|
61
|
+
"""Pick weighted random winners."""
|
|
62
|
+
if not self.entries:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
pool: List[int] = []
|
|
66
|
+
for user_id, entry in self.entries.items():
|
|
67
|
+
pool.extend([user_id] * entry.entries)
|
|
68
|
+
|
|
69
|
+
count = min(self.winner_count, len(self.entries))
|
|
70
|
+
winners: List[int] = []
|
|
71
|
+
while len(winners) < count and pool:
|
|
72
|
+
picked = random.choice(pool)
|
|
73
|
+
winners.append(picked)
|
|
74
|
+
pool = [u for u in pool if u != picked]
|
|
75
|
+
|
|
76
|
+
self.winners = winners
|
|
77
|
+
self.ended = True
|
|
78
|
+
return winners
|
|
79
|
+
|
|
80
|
+
def reroll(self, exclude: List[int] = None) -> List[int]:
|
|
81
|
+
"""Reroll winners, optionally excluding previous winners."""
|
|
82
|
+
exclude = exclude or self.winners
|
|
83
|
+
eligible = {uid: e for uid, e in self.entries.items() if uid not in exclude}
|
|
84
|
+
if not eligible:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
pool: List[int] = []
|
|
88
|
+
for user_id, entry in eligible.items():
|
|
89
|
+
pool.extend([user_id] * entry.entries)
|
|
90
|
+
|
|
91
|
+
count = min(self.winner_count, len(eligible))
|
|
92
|
+
winners: List[int] = []
|
|
93
|
+
while len(winners) < count and pool:
|
|
94
|
+
picked = random.choice(pool)
|
|
95
|
+
winners.append(picked)
|
|
96
|
+
pool = [u for u in pool if u != picked]
|
|
97
|
+
|
|
98
|
+
self.winners = winners
|
|
99
|
+
return winners
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def total_entries(self) -> int:
|
|
103
|
+
return sum(e.entries for e in self.entries.values())
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def participant_count(self) -> int:
|
|
107
|
+
return len(self.entries)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def time_remaining(self) -> float:
|
|
111
|
+
return max(0.0, self.ends_at - time.time())
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_active(self) -> bool:
|
|
115
|
+
return not self.ended and self.time_remaining > 0
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> Dict:
|
|
118
|
+
return {
|
|
119
|
+
"giveaway_id": self.giveaway_id,
|
|
120
|
+
"prize": self.prize,
|
|
121
|
+
"host_id": self.host_id,
|
|
122
|
+
"channel_id": self.channel_id,
|
|
123
|
+
"guild_id": self.guild_id,
|
|
124
|
+
"winner_count": self.winner_count,
|
|
125
|
+
"ends_at": self.ends_at,
|
|
126
|
+
"message_id": self.message_id,
|
|
127
|
+
"required_role_id": self.required_role_id,
|
|
128
|
+
"bonus_roles": self.bonus_roles,
|
|
129
|
+
"entries": {str(k): {"user_id": v.user_id, "entries": v.entries}
|
|
130
|
+
for k, v in self.entries.items()},
|
|
131
|
+
"ended": self.ended,
|
|
132
|
+
"winners": self.winners,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class GiveawayManager:
|
|
137
|
+
"""
|
|
138
|
+
Manages multiple giveaways with automatic scheduling.
|
|
139
|
+
|
|
140
|
+
Usage:
|
|
141
|
+
manager = GiveawayManager()
|
|
142
|
+
manager.on_end = my_end_callback
|
|
143
|
+
g = await manager.create(prize="Nitro", host_id=..., channel_id=...,
|
|
144
|
+
guild_id=..., duration=3600)
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self):
|
|
148
|
+
self._giveaways: Dict[str, Giveaway] = {}
|
|
149
|
+
self.on_end: Optional[Callable] = None
|
|
150
|
+
self._tasks: Dict[str, asyncio.Task] = {}
|
|
151
|
+
|
|
152
|
+
def get(self, giveaway_id: str) -> Optional[Giveaway]:
|
|
153
|
+
return self._giveaways.get(giveaway_id)
|
|
154
|
+
|
|
155
|
+
def get_by_message(self, message_id: int) -> Optional[Giveaway]:
|
|
156
|
+
for g in self._giveaways.values():
|
|
157
|
+
if g.message_id == message_id:
|
|
158
|
+
return g
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def all_active(self) -> List[Giveaway]:
|
|
162
|
+
return [g for g in self._giveaways.values() if g.is_active]
|
|
163
|
+
|
|
164
|
+
def all_ended(self) -> List[Giveaway]:
|
|
165
|
+
return [g for g in self._giveaways.values() if g.ended]
|
|
166
|
+
|
|
167
|
+
async def create(
|
|
168
|
+
self,
|
|
169
|
+
prize: str,
|
|
170
|
+
host_id: int,
|
|
171
|
+
channel_id: int,
|
|
172
|
+
guild_id: int,
|
|
173
|
+
duration: int,
|
|
174
|
+
winner_count: int = 1,
|
|
175
|
+
required_role_id: int = None,
|
|
176
|
+
bonus_roles: Dict[int, int] = None,
|
|
177
|
+
) -> Giveaway:
|
|
178
|
+
"""Create and schedule a giveaway. duration is in seconds."""
|
|
179
|
+
g = Giveaway(
|
|
180
|
+
prize=prize,
|
|
181
|
+
host_id=host_id,
|
|
182
|
+
channel_id=channel_id,
|
|
183
|
+
guild_id=guild_id,
|
|
184
|
+
winner_count=winner_count,
|
|
185
|
+
ends_at=time.time() + duration,
|
|
186
|
+
required_role_id=required_role_id,
|
|
187
|
+
bonus_roles=bonus_roles or {},
|
|
188
|
+
)
|
|
189
|
+
self._giveaways[g.giveaway_id] = g
|
|
190
|
+
self._tasks[g.giveaway_id] = asyncio.create_task(
|
|
191
|
+
self._schedule_end(g, duration)
|
|
192
|
+
)
|
|
193
|
+
return g
|
|
194
|
+
|
|
195
|
+
async def end_now(self, giveaway_id: str) -> Optional[Giveaway]:
|
|
196
|
+
"""Force-end a giveaway immediately."""
|
|
197
|
+
g = self._giveaways.get(giveaway_id)
|
|
198
|
+
if g and not g.ended:
|
|
199
|
+
task = self._tasks.pop(giveaway_id, None)
|
|
200
|
+
if task:
|
|
201
|
+
task.cancel()
|
|
202
|
+
await self._finish(g)
|
|
203
|
+
return g
|
|
204
|
+
|
|
205
|
+
async def cancel(self, giveaway_id: str) -> bool:
|
|
206
|
+
"""Cancel a giveaway without picking winners."""
|
|
207
|
+
g = self._giveaways.pop(giveaway_id, None)
|
|
208
|
+
if g:
|
|
209
|
+
task = self._tasks.pop(giveaway_id, None)
|
|
210
|
+
if task:
|
|
211
|
+
task.cancel()
|
|
212
|
+
g.ended = True
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
async def _schedule_end(self, giveaway: Giveaway, delay: int):
|
|
217
|
+
await asyncio.sleep(delay)
|
|
218
|
+
await self._finish(giveaway)
|
|
219
|
+
|
|
220
|
+
async def _finish(self, giveaway: Giveaway):
|
|
221
|
+
winners = giveaway.pick_winners()
|
|
222
|
+
if self.on_end:
|
|
223
|
+
try:
|
|
224
|
+
await self.on_end(giveaway=giveaway, winners=winners)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
kroxy/discord/music.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.music - Discord music bot utilities and queue management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LoopMode(Enum):
|
|
13
|
+
NONE = "none"
|
|
14
|
+
TRACK = "track"
|
|
15
|
+
QUEUE = "queue"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Track:
|
|
20
|
+
"""Represents a music track."""
|
|
21
|
+
title: str
|
|
22
|
+
url: str
|
|
23
|
+
stream_url: str
|
|
24
|
+
duration: int
|
|
25
|
+
requester_id: int
|
|
26
|
+
thumbnail: Optional[str] = None
|
|
27
|
+
source: str = "unknown"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def duration_str(self) -> str:
|
|
31
|
+
mins, secs = divmod(self.duration, 60)
|
|
32
|
+
hours, mins = divmod(mins, 60)
|
|
33
|
+
if hours:
|
|
34
|
+
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
35
|
+
return f"{mins}:{secs:02d}"
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> Dict:
|
|
38
|
+
return {
|
|
39
|
+
"title": self.title,
|
|
40
|
+
"url": self.url,
|
|
41
|
+
"duration": self.duration,
|
|
42
|
+
"requester_id": self.requester_id,
|
|
43
|
+
"thumbnail": self.thumbnail,
|
|
44
|
+
"source": self.source,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MusicQueue:
|
|
49
|
+
"""Thread-safe music queue."""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self._queue: List[Track] = []
|
|
53
|
+
|
|
54
|
+
def add(self, track: Track):
|
|
55
|
+
self._queue.append(track)
|
|
56
|
+
|
|
57
|
+
def add_next(self, track: Track):
|
|
58
|
+
"""Insert a track at the front of the queue."""
|
|
59
|
+
self._queue.insert(0, track)
|
|
60
|
+
|
|
61
|
+
def pop(self) -> Optional[Track]:
|
|
62
|
+
return self._queue.pop(0) if self._queue else None
|
|
63
|
+
|
|
64
|
+
def peek(self) -> Optional[Track]:
|
|
65
|
+
return self._queue[0] if self._queue else None
|
|
66
|
+
|
|
67
|
+
def remove(self, index: int) -> Optional[Track]:
|
|
68
|
+
if 0 <= index < len(self._queue):
|
|
69
|
+
return self._queue.pop(index)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def move(self, from_index: int, to_index: int) -> bool:
|
|
73
|
+
if not (0 <= from_index < len(self._queue)):
|
|
74
|
+
return False
|
|
75
|
+
track = self._queue.pop(from_index)
|
|
76
|
+
to_index = min(to_index, len(self._queue))
|
|
77
|
+
self._queue.insert(to_index, track)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def shuffle(self):
|
|
81
|
+
import random
|
|
82
|
+
random.shuffle(self._queue)
|
|
83
|
+
|
|
84
|
+
def clear(self):
|
|
85
|
+
self._queue.clear()
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
return len(self._queue)
|
|
89
|
+
|
|
90
|
+
def __iter__(self):
|
|
91
|
+
return iter(self._queue)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_empty(self) -> bool:
|
|
95
|
+
return len(self._queue) == 0
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def total_duration(self) -> int:
|
|
99
|
+
return sum(t.duration for t in self._queue)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MusicPlayer:
|
|
103
|
+
"""
|
|
104
|
+
Guild-scoped music player state manager.
|
|
105
|
+
|
|
106
|
+
Wire this to your actual voice client (discord.py / nextcord / etc.).
|
|
107
|
+
The player tracks state; actual audio playback is done via your voice client.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, guild_id: int, channel_id: int = None, text_channel_id: int = None):
|
|
111
|
+
self.guild_id = guild_id
|
|
112
|
+
self.channel_id = channel_id
|
|
113
|
+
self.text_channel_id = text_channel_id
|
|
114
|
+
self.queue = MusicQueue()
|
|
115
|
+
self.current: Optional[Track] = None
|
|
116
|
+
self.loop_mode: LoopMode = LoopMode.NONE
|
|
117
|
+
self.volume: float = 1.0
|
|
118
|
+
self.paused: bool = False
|
|
119
|
+
self.started_at: Optional[float] = None
|
|
120
|
+
self.paused_at: Optional[float] = None
|
|
121
|
+
self._pause_duration: float = 0.0
|
|
122
|
+
|
|
123
|
+
self.on_track_start: Optional[Callable] = None
|
|
124
|
+
self.on_track_end: Optional[Callable] = None
|
|
125
|
+
self.on_queue_empty: Optional[Callable] = None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def position(self) -> float:
|
|
129
|
+
"""Current playback position in seconds."""
|
|
130
|
+
if self.started_at is None:
|
|
131
|
+
return 0.0
|
|
132
|
+
if self.paused and self.paused_at:
|
|
133
|
+
return self.paused_at - self.started_at - self._pause_duration
|
|
134
|
+
return time.time() - self.started_at - self._pause_duration
|
|
135
|
+
|
|
136
|
+
def set_volume(self, volume: float) -> float:
|
|
137
|
+
"""Set volume 0.0 – 2.0. Returns clamped value."""
|
|
138
|
+
self.volume = max(0.0, min(2.0, volume))
|
|
139
|
+
return self.volume
|
|
140
|
+
|
|
141
|
+
def set_loop(self, mode: str) -> LoopMode:
|
|
142
|
+
"""Set loop mode: 'none', 'track', or 'queue'."""
|
|
143
|
+
self.loop_mode = LoopMode(mode.lower())
|
|
144
|
+
return self.loop_mode
|
|
145
|
+
|
|
146
|
+
def toggle_pause(self) -> bool:
|
|
147
|
+
"""Toggle pause state. Returns new paused state."""
|
|
148
|
+
if self.paused:
|
|
149
|
+
self._pause_duration += time.time() - (self.paused_at or time.time())
|
|
150
|
+
self.paused_at = None
|
|
151
|
+
self.paused = False
|
|
152
|
+
else:
|
|
153
|
+
self.paused_at = time.time()
|
|
154
|
+
self.paused = True
|
|
155
|
+
return self.paused
|
|
156
|
+
|
|
157
|
+
async def play_next(self) -> Optional[Track]:
|
|
158
|
+
"""Advance to the next track based on loop mode."""
|
|
159
|
+
if self.loop_mode == LoopMode.TRACK and self.current:
|
|
160
|
+
next_track = self.current
|
|
161
|
+
elif self.loop_mode == LoopMode.QUEUE and self.current:
|
|
162
|
+
self.queue.add(self.current)
|
|
163
|
+
next_track = self.queue.pop()
|
|
164
|
+
else:
|
|
165
|
+
next_track = self.queue.pop()
|
|
166
|
+
|
|
167
|
+
if next_track is None:
|
|
168
|
+
self.current = None
|
|
169
|
+
self.started_at = None
|
|
170
|
+
if self.on_queue_empty:
|
|
171
|
+
await self.on_queue_empty(guild_id=self.guild_id)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
self.current = next_track
|
|
175
|
+
self.started_at = time.time()
|
|
176
|
+
self.paused = False
|
|
177
|
+
self._pause_duration = 0.0
|
|
178
|
+
|
|
179
|
+
if self.on_track_start:
|
|
180
|
+
await self.on_track_start(track=next_track, player=self)
|
|
181
|
+
return next_track
|
|
182
|
+
|
|
183
|
+
async def skip(self) -> Optional[Track]:
|
|
184
|
+
"""Skip the current track."""
|
|
185
|
+
if self.on_track_end and self.current:
|
|
186
|
+
await self.on_track_end(track=self.current, player=self)
|
|
187
|
+
old_loop = self.loop_mode
|
|
188
|
+
self.loop_mode = LoopMode.NONE
|
|
189
|
+
next_track = await self.play_next()
|
|
190
|
+
self.loop_mode = old_loop
|
|
191
|
+
return next_track
|
|
192
|
+
|
|
193
|
+
def stop(self):
|
|
194
|
+
"""Stop playback and clear queue."""
|
|
195
|
+
self.current = None
|
|
196
|
+
self.queue.clear()
|
|
197
|
+
self.started_at = None
|
|
198
|
+
self.paused = False
|
|
199
|
+
|
|
200
|
+
def get_state(self) -> Dict:
|
|
201
|
+
"""Return a snapshot of the player state."""
|
|
202
|
+
return {
|
|
203
|
+
"guild_id": self.guild_id,
|
|
204
|
+
"channel_id": self.channel_id,
|
|
205
|
+
"current": self.current.to_dict() if self.current else None,
|
|
206
|
+
"queue_length": len(self.queue),
|
|
207
|
+
"queue_duration": self.queue.total_duration,
|
|
208
|
+
"loop_mode": self.loop_mode.value,
|
|
209
|
+
"volume": self.volume,
|
|
210
|
+
"paused": self.paused,
|
|
211
|
+
"position": round(self.position, 1),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class MusicPlayerManager:
|
|
216
|
+
"""Manages one MusicPlayer per guild."""
|
|
217
|
+
|
|
218
|
+
def __init__(self):
|
|
219
|
+
self._players: Dict[int, MusicPlayer] = {}
|
|
220
|
+
|
|
221
|
+
def get(self, guild_id: int) -> Optional[MusicPlayer]:
|
|
222
|
+
return self._players.get(guild_id)
|
|
223
|
+
|
|
224
|
+
def get_or_create(self, guild_id: int, channel_id: int = None,
|
|
225
|
+
text_channel_id: int = None) -> MusicPlayer:
|
|
226
|
+
if guild_id not in self._players:
|
|
227
|
+
self._players[guild_id] = MusicPlayer(guild_id, channel_id, text_channel_id)
|
|
228
|
+
return self._players[guild_id]
|
|
229
|
+
|
|
230
|
+
def remove(self, guild_id: int):
|
|
231
|
+
player = self._players.pop(guild_id, None)
|
|
232
|
+
if player:
|
|
233
|
+
player.stop()
|
|
234
|
+
|
|
235
|
+
def all_players(self) -> List[MusicPlayer]:
|
|
236
|
+
return list(self._players.values())
|
kroxy/discord/utils.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.utils - General Discord utility functions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import datetime
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Utils:
|
|
12
|
+
"""Collection of Discord utility helpers."""
|
|
13
|
+
|
|
14
|
+
# ── Embed builder ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def build_embed(
|
|
18
|
+
title: str = None,
|
|
19
|
+
description: str = None,
|
|
20
|
+
color: int = 0x5865F2,
|
|
21
|
+
fields: list = None,
|
|
22
|
+
footer: str = None,
|
|
23
|
+
thumbnail: str = None,
|
|
24
|
+
image: str = None,
|
|
25
|
+
author_name: str = None,
|
|
26
|
+
author_icon: str = None,
|
|
27
|
+
timestamp: bool = False,
|
|
28
|
+
) -> dict:
|
|
29
|
+
"""Build a Discord embed dict."""
|
|
30
|
+
embed = {"color": color}
|
|
31
|
+
if title:
|
|
32
|
+
embed["title"] = title
|
|
33
|
+
if description:
|
|
34
|
+
embed["description"] = description
|
|
35
|
+
if fields:
|
|
36
|
+
embed["fields"] = [
|
|
37
|
+
{"name": f["name"], "value": f["value"],
|
|
38
|
+
"inline": f.get("inline", False)}
|
|
39
|
+
for f in fields
|
|
40
|
+
]
|
|
41
|
+
if footer:
|
|
42
|
+
embed["footer"] = {"text": footer}
|
|
43
|
+
if thumbnail:
|
|
44
|
+
embed["thumbnail"] = {"url": thumbnail}
|
|
45
|
+
if image:
|
|
46
|
+
embed["image"] = {"url": image}
|
|
47
|
+
if author_name:
|
|
48
|
+
embed["author"] = {"name": author_name}
|
|
49
|
+
if author_icon:
|
|
50
|
+
embed["author"]["icon_url"] = author_icon
|
|
51
|
+
if timestamp:
|
|
52
|
+
embed["timestamp"] = datetime.datetime.utcnow().isoformat()
|
|
53
|
+
return embed
|
|
54
|
+
|
|
55
|
+
# ── Mention helpers ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def mention_user(user_id: int) -> str:
|
|
59
|
+
return f"<@{user_id}>"
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def mention_role(role_id: int) -> str:
|
|
63
|
+
return f"<@&{role_id}>"
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def mention_channel(channel_id: int) -> str:
|
|
67
|
+
return f"<#{channel_id}>"
|
|
68
|
+
|
|
69
|
+
# ── ID parsing ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def parse_mention(mention: str) -> Optional[int]:
|
|
73
|
+
"""Extract a raw ID from a Discord mention string."""
|
|
74
|
+
match = re.match(r"<[@&#!]{0,2}(\d+)>", mention)
|
|
75
|
+
return int(match.group(1)) if match else None
|
|
76
|
+
|
|
77
|
+
# ── Time helpers ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def discord_timestamp(dt: Union[datetime.datetime, int, float],
|
|
81
|
+
style: str = "f") -> str:
|
|
82
|
+
"""
|
|
83
|
+
Format a datetime as a Discord timestamp.
|
|
84
|
+
Styles: t, T, d, D, f, F, R
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(dt, datetime.datetime):
|
|
87
|
+
ts = int(dt.timestamp())
|
|
88
|
+
else:
|
|
89
|
+
ts = int(dt)
|
|
90
|
+
return f"<t:{ts}:{style}>"
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def time_until(seconds: int) -> str:
|
|
94
|
+
"""Human-readable countdown string from seconds."""
|
|
95
|
+
periods = [
|
|
96
|
+
("day", 86400),
|
|
97
|
+
("hour", 3600),
|
|
98
|
+
("minute", 60),
|
|
99
|
+
("second", 1),
|
|
100
|
+
]
|
|
101
|
+
parts = []
|
|
102
|
+
for name, period in periods:
|
|
103
|
+
value, seconds = divmod(seconds, period)
|
|
104
|
+
if value:
|
|
105
|
+
parts.append(f"{value} {name}{'s' if value != 1 else ''}")
|
|
106
|
+
return ", ".join(parts) if parts else "0 seconds"
|
|
107
|
+
|
|
108
|
+
# ── Snowflake ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def snowflake_to_timestamp(snowflake: int) -> datetime.datetime:
|
|
112
|
+
"""Convert a Discord snowflake ID to a UTC datetime."""
|
|
113
|
+
timestamp_ms = (snowflake >> 22) + 1420070400000
|
|
114
|
+
return datetime.datetime.utcfromtimestamp(timestamp_ms / 1000)
|
|
115
|
+
|
|
116
|
+
# ── Permission helpers ────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
PERMISSIONS = {
|
|
119
|
+
"administrator": 1 << 3,
|
|
120
|
+
"manage_guild": 1 << 5,
|
|
121
|
+
"manage_roles": 1 << 28,
|
|
122
|
+
"manage_channels": 1 << 4,
|
|
123
|
+
"kick_members": 1 << 1,
|
|
124
|
+
"ban_members": 1 << 2,
|
|
125
|
+
"manage_messages": 1 << 13,
|
|
126
|
+
"manage_webhooks": 1 << 29,
|
|
127
|
+
"manage_nicknames": 1 << 27,
|
|
128
|
+
"mention_everyone": 1 << 17,
|
|
129
|
+
"send_messages": 1 << 11,
|
|
130
|
+
"embed_links": 1 << 14,
|
|
131
|
+
"attach_files": 1 << 15,
|
|
132
|
+
"read_message_history": 1 << 16,
|
|
133
|
+
"connect": 1 << 20,
|
|
134
|
+
"speak": 1 << 21,
|
|
135
|
+
"mute_members": 1 << 22,
|
|
136
|
+
"deafen_members": 1 << 23,
|
|
137
|
+
"move_members": 1 << 24,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def has_permission(cls, permissions: int, permission_name: str) -> bool:
|
|
142
|
+
"""Check if a permission bitfield includes a named permission."""
|
|
143
|
+
flag = cls.PERMISSIONS.get(permission_name.lower())
|
|
144
|
+
if flag is None:
|
|
145
|
+
raise ValueError(f"Unknown permission: {permission_name}")
|
|
146
|
+
return bool(permissions & cls.PERMISSIONS["administrator"] or permissions & flag)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def permissions_list(cls, permissions: int) -> list:
|
|
150
|
+
"""Return a list of permission names from a bitfield."""
|
|
151
|
+
return [name for name, flag in cls.PERMISSIONS.items()
|
|
152
|
+
if permissions & flag]
|
|
153
|
+
|
|
154
|
+
# ── Text helpers ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def truncate(text: str, max_length: int = 2048, suffix: str = "...") -> str:
|
|
158
|
+
"""Truncate text to fit Discord's limits."""
|
|
159
|
+
if len(text) <= max_length:
|
|
160
|
+
return text
|
|
161
|
+
return text[: max_length - len(suffix)] + suffix
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def code_block(content: str, language: str = "") -> str:
|
|
165
|
+
"""Wrap content in a Discord code block."""
|
|
166
|
+
return f"```{language}\n{content}\n```"
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def inline_code(content: str) -> str:
|
|
170
|
+
return f"`{content}`"
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def bold(text: str) -> str:
|
|
174
|
+
return f"**{text}**"
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def italic(text: str) -> str:
|
|
178
|
+
return f"*{text}*"
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def underline(text: str) -> str:
|
|
182
|
+
return f"__{text}__"
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def strikethrough(text: str) -> str:
|
|
186
|
+
return f"~~{text}~~"
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def spoiler(text: str) -> str:
|
|
190
|
+
return f"||{text}||"
|