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.
@@ -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}||"
@@ -0,0 +1,5 @@
1
+ """
2
+ kroxy.website - Website utilities (coming soon).
3
+ """
4
+
5
+ __all__ = []