kroxy 1.0.2__tar.gz → 1.0.3__tar.gz
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-1.0.2/kroxy.egg-info → kroxy-1.0.3}/PKG-INFO +2 -2
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/__init__.py +31 -49
- kroxy-1.0.3/kroxy/discord/__init__.py +31 -0
- kroxy-1.0.3/kroxy/discord/cache.py +202 -0
- kroxy-1.0.3/kroxy/discord/embed.py +275 -0
- kroxy-1.0.3/kroxy/discord/logger.py +237 -0
- kroxy-1.0.3/kroxy/discord/moderation.py +534 -0
- kroxy-1.0.3/kroxy/discord/webhook.py +238 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/website/__init__.py +3 -2
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/website/fetch.py +93 -0
- kroxy-1.0.3/kroxy/website/monitor.py +222 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/website/scraper.py +168 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/website/utils.py +121 -0
- {kroxy-1.0.2 → kroxy-1.0.3/kroxy.egg-info}/PKG-INFO +2 -2
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy.egg-info/SOURCES.txt +6 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/pyproject.toml +2 -2
- kroxy-1.0.2/kroxy/discord/__init__.py +0 -22
- {kroxy-1.0.2 → kroxy-1.0.3}/LICENSE +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/MANIFEST.in +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/README.md +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/antinuke.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/api.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/checkers.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/commands.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/giveaway.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/music.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy/discord/utils.py +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy.egg-info/dependency_links.txt +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy.egg-info/requires.txt +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/kroxy.egg-info/top_level.txt +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/setup.cfg +0 -0
- {kroxy-1.0.2 → kroxy-1.0.3}/setup.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kroxy
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: A powerful Python toolkit for building automation, integrations, and bots.
|
|
5
5
|
Author-email: kroxy <kroxy@example.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
7
7
|
Project-URL: Homepage, https://pypi.org/project/kroxy
|
|
8
|
-
Keywords: bot,automation,antinuke,giveaway,music,scraper,toolkit
|
|
8
|
+
Keywords: bot,automation,antinuke,giveaway,music,scraper,webhook,cache,moderation,toolkit
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.9
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -6,15 +6,10 @@ A powerful Python toolkit for building automation, integrations, and bots.
|
|
|
6
6
|
All classes are available directly from the top-level `kroxy` namespace.
|
|
7
7
|
|
|
8
8
|
import kroxy
|
|
9
|
-
kroxy
|
|
10
|
-
kroxy
|
|
11
|
-
kroxy
|
|
12
|
-
kroxy
|
|
13
|
-
kroxy.WebUtils.slugify("Hello World")
|
|
14
|
-
|
|
15
|
-
from kroxy import AntiNuke, Utils, Checkers, Fetcher, Scraper, WebUtils
|
|
16
|
-
from kroxy import discord # discord submodule
|
|
17
|
-
from kroxy import website # website submodule
|
|
9
|
+
from kroxy import AntiNuke, Embed, Moderation, Cache, Webhook
|
|
10
|
+
from kroxy import Fetcher, Scraper, WebUtils, Monitor
|
|
11
|
+
from kroxy import discord # full discord submodule
|
|
12
|
+
from kroxy import website # full website submodule
|
|
18
13
|
|
|
19
14
|
License
|
|
20
15
|
-------
|
|
@@ -22,7 +17,7 @@ Proprietary — Copyright (c) 2026 kroxy. All rights reserved.
|
|
|
22
17
|
Unauthorized copying, modification, or redistribution is strictly prohibited.
|
|
23
18
|
"""
|
|
24
19
|
|
|
25
|
-
__version__ = "1.0.
|
|
20
|
+
__version__ = "1.0.3"
|
|
26
21
|
__author__ = "kroxy"
|
|
27
22
|
__license__ = "Proprietary"
|
|
28
23
|
__copyright__ = "Copyright (c) 2026 kroxy. All rights reserved."
|
|
@@ -33,70 +28,57 @@ from kroxy import website
|
|
|
33
28
|
|
|
34
29
|
# ── Discord ────────────────────────────────────────────────────────────────────
|
|
35
30
|
from kroxy.discord.api import DiscordAPI
|
|
36
|
-
|
|
37
|
-
from kroxy.discord.commands import (
|
|
38
|
-
SlashCommand,
|
|
39
|
-
PrefixCommand,
|
|
40
|
-
Option,
|
|
41
|
-
CommandRegistry,
|
|
42
|
-
CooldownError,
|
|
43
|
-
)
|
|
44
|
-
|
|
31
|
+
from kroxy.discord.commands import SlashCommand, PrefixCommand, Option, CommandRegistry, CooldownError
|
|
45
32
|
from kroxy.discord.utils import Utils
|
|
46
|
-
|
|
47
33
|
from kroxy.discord.antinuke import AntiNuke, ActionLog
|
|
48
|
-
|
|
49
34
|
from kroxy.discord.checkers import Checkers, CheckFailed
|
|
50
|
-
|
|
51
35
|
from kroxy.discord.giveaway import GiveawayManager, Giveaway, GiveawayEntry
|
|
52
|
-
|
|
53
|
-
from kroxy.discord.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
LoopMode,
|
|
59
|
-
)
|
|
36
|
+
from kroxy.discord.music import MusicPlayerManager, MusicPlayer, MusicQueue, Track, LoopMode
|
|
37
|
+
from kroxy.discord.moderation import Moderation, Warning
|
|
38
|
+
from kroxy.discord.embed import Embed
|
|
39
|
+
from kroxy.discord.logger import ModLogger
|
|
40
|
+
from kroxy.discord.cache import Cache
|
|
41
|
+
from kroxy.discord.webhook import Webhook
|
|
60
42
|
|
|
61
43
|
# ── Website ────────────────────────────────────────────────────────────────────
|
|
62
44
|
from kroxy.website.fetch import Fetcher
|
|
63
45
|
from kroxy.website.scraper import Scraper
|
|
64
46
|
from kroxy.website.utils import WebUtils
|
|
47
|
+
from kroxy.website.monitor import Monitor, CheckResult
|
|
65
48
|
|
|
66
49
|
__all__ = [
|
|
67
50
|
# Submodules
|
|
68
|
-
"discord",
|
|
69
|
-
"website",
|
|
51
|
+
"discord", "website",
|
|
70
52
|
# Discord — API
|
|
71
53
|
"DiscordAPI",
|
|
72
54
|
# Discord — Commands
|
|
73
|
-
"SlashCommand",
|
|
74
|
-
"PrefixCommand",
|
|
75
|
-
"Option",
|
|
76
|
-
"CommandRegistry",
|
|
77
|
-
"CooldownError",
|
|
55
|
+
"SlashCommand", "PrefixCommand", "Option", "CommandRegistry", "CooldownError",
|
|
78
56
|
# Discord — Utils
|
|
79
57
|
"Utils",
|
|
80
58
|
# Discord — Anti-Nuke
|
|
81
|
-
"AntiNuke",
|
|
82
|
-
"ActionLog",
|
|
59
|
+
"AntiNuke", "ActionLog",
|
|
83
60
|
# Discord — Checkers
|
|
84
|
-
"Checkers",
|
|
85
|
-
"CheckFailed",
|
|
61
|
+
"Checkers", "CheckFailed",
|
|
86
62
|
# Discord — Giveaway
|
|
87
|
-
"GiveawayManager",
|
|
88
|
-
"Giveaway",
|
|
89
|
-
"GiveawayEntry",
|
|
63
|
+
"GiveawayManager", "Giveaway", "GiveawayEntry",
|
|
90
64
|
# Discord — Music
|
|
91
|
-
"MusicPlayerManager",
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
"
|
|
65
|
+
"MusicPlayerManager", "MusicPlayer", "MusicQueue", "Track", "LoopMode",
|
|
66
|
+
# Discord — Moderation
|
|
67
|
+
"Moderation", "Warning",
|
|
68
|
+
# Discord — Embed
|
|
69
|
+
"Embed",
|
|
70
|
+
# Discord — Logger
|
|
71
|
+
"ModLogger",
|
|
72
|
+
# Discord — Cache
|
|
73
|
+
"Cache",
|
|
74
|
+
# Discord — Webhook
|
|
75
|
+
"Webhook",
|
|
96
76
|
# Website — Fetch
|
|
97
77
|
"Fetcher",
|
|
98
78
|
# Website — Scraper
|
|
99
79
|
"Scraper",
|
|
100
80
|
# Website — Utils
|
|
101
81
|
"WebUtils",
|
|
82
|
+
# Website — Monitor
|
|
83
|
+
"Monitor", "CheckResult",
|
|
102
84
|
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord - Discord bot utilities, automation, and server management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from kroxy.discord.api import DiscordAPI
|
|
6
|
+
from kroxy.discord.commands import SlashCommand, PrefixCommand, Option, CommandRegistry, CooldownError
|
|
7
|
+
from kroxy.discord.utils import Utils
|
|
8
|
+
from kroxy.discord.antinuke import AntiNuke, ActionLog
|
|
9
|
+
from kroxy.discord.checkers import Checkers, CheckFailed
|
|
10
|
+
from kroxy.discord.giveaway import GiveawayManager, Giveaway, GiveawayEntry
|
|
11
|
+
from kroxy.discord.music import MusicPlayerManager, MusicPlayer, MusicQueue, Track, LoopMode
|
|
12
|
+
from kroxy.discord.moderation import Moderation, Warning
|
|
13
|
+
from kroxy.discord.embed import Embed
|
|
14
|
+
from kroxy.discord.logger import ModLogger
|
|
15
|
+
from kroxy.discord.cache import Cache
|
|
16
|
+
from kroxy.discord.webhook import Webhook
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DiscordAPI",
|
|
20
|
+
"SlashCommand", "PrefixCommand", "Option", "CommandRegistry", "CooldownError",
|
|
21
|
+
"Utils",
|
|
22
|
+
"AntiNuke", "ActionLog",
|
|
23
|
+
"Checkers", "CheckFailed",
|
|
24
|
+
"GiveawayManager", "Giveaway", "GiveawayEntry",
|
|
25
|
+
"MusicPlayerManager", "MusicPlayer", "MusicQueue", "Track", "LoopMode",
|
|
26
|
+
"Moderation", "Warning",
|
|
27
|
+
"Embed",
|
|
28
|
+
"ModLogger",
|
|
29
|
+
"Cache",
|
|
30
|
+
"Webhook",
|
|
31
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.cache - TTL in-memory cache for bot data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Entry:
|
|
11
|
+
__slots__ = ("value", "expires_at")
|
|
12
|
+
|
|
13
|
+
def __init__(self, value: Any, expires_at: float):
|
|
14
|
+
self.value = value
|
|
15
|
+
self.expires_at = expires_at
|
|
16
|
+
|
|
17
|
+
def is_expired(self) -> bool:
|
|
18
|
+
return time.time() > self.expires_at
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cache:
|
|
22
|
+
"""
|
|
23
|
+
Thread-safe TTL in-memory cache.
|
|
24
|
+
|
|
25
|
+
Ideal for caching Discord API responses, guild configs,
|
|
26
|
+
user data, cooldowns, and any frequently-read values.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
from kroxy import Cache
|
|
30
|
+
|
|
31
|
+
cache = Cache(default_ttl=300) # 5 minute default TTL
|
|
32
|
+
cache.set("user:123", user_data)
|
|
33
|
+
user = cache.get("user:123")
|
|
34
|
+
cache.delete("user:123")
|
|
35
|
+
|
|
36
|
+
# get or compute
|
|
37
|
+
guild = await cache.get_or_set("guild:456", lambda: fetch_guild(456), ttl=60)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, default_ttl: int = 300):
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
default_ttl: Default time-to-live in seconds for cached entries.
|
|
44
|
+
"""
|
|
45
|
+
self._store: Dict[str, _Entry] = {}
|
|
46
|
+
self.default_ttl = default_ttl
|
|
47
|
+
|
|
48
|
+
def set(self, key: str, value: Any, ttl: int = None) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Store a value in the cache.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
key: Cache key string.
|
|
54
|
+
value: Value to store (any type).
|
|
55
|
+
ttl: Time-to-live in seconds. Uses default_ttl if not specified.
|
|
56
|
+
"""
|
|
57
|
+
ttl = ttl if ttl is not None else self.default_ttl
|
|
58
|
+
self._store[key] = _Entry(value=value, expires_at=time.time() + ttl)
|
|
59
|
+
|
|
60
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
61
|
+
"""
|
|
62
|
+
Retrieve a value from the cache.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: Cache key string.
|
|
66
|
+
default: Value to return if key is missing or expired.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Cached value or default.
|
|
70
|
+
"""
|
|
71
|
+
entry = self._store.get(key)
|
|
72
|
+
if entry is None or entry.is_expired():
|
|
73
|
+
self._store.pop(key, None)
|
|
74
|
+
return default
|
|
75
|
+
return entry.value
|
|
76
|
+
|
|
77
|
+
def delete(self, key: str) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Remove a key from the cache.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if the key existed and was removed, False otherwise.
|
|
83
|
+
"""
|
|
84
|
+
return self._store.pop(key, None) is not None
|
|
85
|
+
|
|
86
|
+
def has(self, key: str) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Check if a key exists in the cache and has not expired.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if the key is present and valid.
|
|
92
|
+
"""
|
|
93
|
+
entry = self._store.get(key)
|
|
94
|
+
if entry is None or entry.is_expired():
|
|
95
|
+
self._store.pop(key, None)
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def clear(self) -> int:
|
|
100
|
+
"""
|
|
101
|
+
Remove all entries from the cache.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Number of entries that were cleared.
|
|
105
|
+
"""
|
|
106
|
+
count = len(self._store)
|
|
107
|
+
self._store.clear()
|
|
108
|
+
return count
|
|
109
|
+
|
|
110
|
+
def size(self) -> int:
|
|
111
|
+
"""
|
|
112
|
+
Count non-expired entries.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Number of valid (non-expired) entries.
|
|
116
|
+
"""
|
|
117
|
+
now = time.time()
|
|
118
|
+
return sum(1 for e in self._store.values() if e.expires_at > now)
|
|
119
|
+
|
|
120
|
+
def keys(self) -> List[str]:
|
|
121
|
+
"""
|
|
122
|
+
Return all active (non-expired) keys.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of key strings.
|
|
126
|
+
"""
|
|
127
|
+
now = time.time()
|
|
128
|
+
return [k for k, e in self._store.items() if e.expires_at > now]
|
|
129
|
+
|
|
130
|
+
async def get_or_set(self, key: str, factory: Callable,
|
|
131
|
+
ttl: int = None) -> Any:
|
|
132
|
+
"""
|
|
133
|
+
Return cached value if available, otherwise call factory(), cache and return the result.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
key: Cache key.
|
|
137
|
+
factory: Async or sync callable that produces the value.
|
|
138
|
+
ttl: TTL for the new entry.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Cached or freshly computed value.
|
|
142
|
+
"""
|
|
143
|
+
value = self.get(key)
|
|
144
|
+
if value is not None:
|
|
145
|
+
return value
|
|
146
|
+
if asyncio.iscoroutinefunction(factory):
|
|
147
|
+
value = await factory()
|
|
148
|
+
else:
|
|
149
|
+
value = factory()
|
|
150
|
+
self.set(key, value, ttl=ttl)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
def expire(self, key: str, new_ttl: int) -> bool:
|
|
154
|
+
"""
|
|
155
|
+
Update the TTL of an existing non-expired cache entry.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
key: Cache key.
|
|
159
|
+
new_ttl: New TTL in seconds from now.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if the key existed and was updated, False otherwise.
|
|
163
|
+
"""
|
|
164
|
+
entry = self._store.get(key)
|
|
165
|
+
if entry is None or entry.is_expired():
|
|
166
|
+
return False
|
|
167
|
+
entry.expires_at = time.time() + new_ttl
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def cleanup(self) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Remove all expired entries to free memory.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of expired entries removed.
|
|
176
|
+
"""
|
|
177
|
+
now = time.time()
|
|
178
|
+
expired = [k for k, e in self._store.items() if e.expires_at <= now]
|
|
179
|
+
for k in expired:
|
|
180
|
+
del self._store[k]
|
|
181
|
+
return len(expired)
|
|
182
|
+
|
|
183
|
+
def get_ttl(self, key: str) -> Optional[float]:
|
|
184
|
+
"""
|
|
185
|
+
Get remaining TTL for a key in seconds.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Seconds remaining, or None if key is missing/expired.
|
|
189
|
+
"""
|
|
190
|
+
entry = self._store.get(key)
|
|
191
|
+
if entry is None or entry.is_expired():
|
|
192
|
+
return None
|
|
193
|
+
return max(0.0, entry.expires_at - time.time())
|
|
194
|
+
|
|
195
|
+
def __len__(self) -> int:
|
|
196
|
+
return self.size()
|
|
197
|
+
|
|
198
|
+
def __contains__(self, key: str) -> bool:
|
|
199
|
+
return self.has(key)
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
return f"<Cache size={self.size()} default_ttl={self.default_ttl}s>"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.embed - OOP chainable Discord embed builder.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Embed:
|
|
11
|
+
"""
|
|
12
|
+
Chainable, OOP-style Discord embed builder.
|
|
13
|
+
|
|
14
|
+
All setter methods return `self` so they can be chained.
|
|
15
|
+
|
|
16
|
+
Discord limits enforced by is_valid() / truncate_all():
|
|
17
|
+
- Title: 256 chars
|
|
18
|
+
- Description: 4096 chars
|
|
19
|
+
- Fields: 25 max
|
|
20
|
+
- Field name: 256 chars
|
|
21
|
+
- Field value: 1024 chars
|
|
22
|
+
- Footer text: 2048 chars
|
|
23
|
+
- Author name: 256 chars
|
|
24
|
+
- Total chars: 6000
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
embed = (
|
|
28
|
+
Embed(title="Hello", color=0x5865F2)
|
|
29
|
+
.set_description("This is an embed.")
|
|
30
|
+
.add_field("Field 1", "Value 1", inline=True)
|
|
31
|
+
.add_field("Field 2", "Value 2", inline=True)
|
|
32
|
+
.set_footer("kroxy")
|
|
33
|
+
.set_timestamp()
|
|
34
|
+
)
|
|
35
|
+
await api.send_message(channel_id, embed=embed.to_dict())
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
LIMITS = {
|
|
39
|
+
"title": 256,
|
|
40
|
+
"description": 4096,
|
|
41
|
+
"field_name": 256,
|
|
42
|
+
"field_value": 1024,
|
|
43
|
+
"footer_text": 2048,
|
|
44
|
+
"author_name": 256,
|
|
45
|
+
"total": 6000,
|
|
46
|
+
"fields": 25,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __init__(self, title: str = None, description: str = None,
|
|
50
|
+
color: int = 0x5865F2):
|
|
51
|
+
self._title: Optional[str] = title
|
|
52
|
+
self._description: Optional[str] = description
|
|
53
|
+
self._color: int = color
|
|
54
|
+
self._fields: List[Dict] = []
|
|
55
|
+
self._footer: Optional[Dict] = None
|
|
56
|
+
self._thumbnail: Optional[Dict] = None
|
|
57
|
+
self._image: Optional[Dict] = None
|
|
58
|
+
self._author: Optional[Dict] = None
|
|
59
|
+
self._timestamp: Optional[str] = None
|
|
60
|
+
self._url: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
# ── Setters (all chainable) ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def set_title(self, title: str) -> "Embed":
|
|
65
|
+
"""Set the embed title."""
|
|
66
|
+
self._title = title
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def set_description(self, description: str) -> "Embed":
|
|
70
|
+
"""Set the embed description."""
|
|
71
|
+
self._description = description
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def set_color(self, color: int) -> "Embed":
|
|
75
|
+
"""Set the embed color as an integer (e.g. 0x5865F2)."""
|
|
76
|
+
self._color = color
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def set_url(self, url: str) -> "Embed":
|
|
80
|
+
"""Set the embed URL (makes the title a hyperlink)."""
|
|
81
|
+
self._url = url
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def add_field(self, name: str, value: str,
|
|
85
|
+
inline: bool = False) -> "Embed":
|
|
86
|
+
"""Add a field to the embed."""
|
|
87
|
+
self._fields.append({"name": name, "value": value, "inline": inline})
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def remove_field(self, index: int) -> "Embed":
|
|
91
|
+
"""Remove a field by its index (0-based)."""
|
|
92
|
+
if 0 <= index < len(self._fields):
|
|
93
|
+
self._fields.pop(index)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def clear_fields(self) -> "Embed":
|
|
97
|
+
"""Remove all fields from the embed."""
|
|
98
|
+
self._fields.clear()
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def set_footer(self, text: str, icon_url: str = None) -> "Embed":
|
|
102
|
+
"""Set the embed footer."""
|
|
103
|
+
self._footer = {"text": text}
|
|
104
|
+
if icon_url:
|
|
105
|
+
self._footer["icon_url"] = icon_url
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def set_thumbnail(self, url: str) -> "Embed":
|
|
109
|
+
"""Set the embed thumbnail (small image in top-right corner)."""
|
|
110
|
+
self._thumbnail = {"url": url}
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def set_image(self, url: str) -> "Embed":
|
|
114
|
+
"""Set the embed large image (shown below fields)."""
|
|
115
|
+
self._image = {"url": url}
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def set_author(self, name: str, url: str = None,
|
|
119
|
+
icon_url: str = None) -> "Embed":
|
|
120
|
+
"""Set the embed author section."""
|
|
121
|
+
self._author = {"name": name}
|
|
122
|
+
if url:
|
|
123
|
+
self._author["url"] = url
|
|
124
|
+
if icon_url:
|
|
125
|
+
self._author["icon_url"] = icon_url
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def set_timestamp(self, dt: datetime.datetime = None) -> "Embed":
|
|
129
|
+
"""
|
|
130
|
+
Set the embed timestamp.
|
|
131
|
+
If dt is None, uses the current UTC time.
|
|
132
|
+
"""
|
|
133
|
+
dt = dt or datetime.datetime.utcnow()
|
|
134
|
+
self._timestamp = dt.isoformat()
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
# ── Export ────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> Dict:
|
|
140
|
+
"""Export the embed as a Discord-compatible dict."""
|
|
141
|
+
data: Dict[str, Any] = {"color": self._color}
|
|
142
|
+
if self._title:
|
|
143
|
+
data["title"] = self._title
|
|
144
|
+
if self._description:
|
|
145
|
+
data["description"] = self._description
|
|
146
|
+
if self._url:
|
|
147
|
+
data["url"] = self._url
|
|
148
|
+
if self._fields:
|
|
149
|
+
data["fields"] = list(self._fields)
|
|
150
|
+
if self._footer:
|
|
151
|
+
data["footer"] = self._footer
|
|
152
|
+
if self._thumbnail:
|
|
153
|
+
data["thumbnail"] = self._thumbnail
|
|
154
|
+
if self._image:
|
|
155
|
+
data["image"] = self._image
|
|
156
|
+
if self._author:
|
|
157
|
+
data["author"] = self._author
|
|
158
|
+
if self._timestamp:
|
|
159
|
+
data["timestamp"] = self._timestamp
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, data: Dict) -> "Embed":
|
|
164
|
+
"""
|
|
165
|
+
Create an Embed from an existing Discord embed dict.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Embed instance populated from the dict.
|
|
169
|
+
"""
|
|
170
|
+
e = cls(
|
|
171
|
+
title=data.get("title"),
|
|
172
|
+
description=data.get("description"),
|
|
173
|
+
color=data.get("color", 0x5865F2),
|
|
174
|
+
)
|
|
175
|
+
e._url = data.get("url")
|
|
176
|
+
e._fields = list(data.get("fields", []))
|
|
177
|
+
e._footer = data.get("footer")
|
|
178
|
+
e._thumbnail = data.get("thumbnail")
|
|
179
|
+
e._image = data.get("image")
|
|
180
|
+
e._author = data.get("author")
|
|
181
|
+
e._timestamp = data.get("timestamp")
|
|
182
|
+
return e
|
|
183
|
+
|
|
184
|
+
def copy(self) -> "Embed":
|
|
185
|
+
"""Return a deep copy of this embed."""
|
|
186
|
+
return Embed.from_dict(self.to_dict())
|
|
187
|
+
|
|
188
|
+
def merge(self, other: "Embed") -> "Embed":
|
|
189
|
+
"""
|
|
190
|
+
Merge another embed's fields into this one (fields are appended).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
self (chainable)
|
|
194
|
+
"""
|
|
195
|
+
for f in other._fields:
|
|
196
|
+
self._fields.append(dict(f))
|
|
197
|
+
if not self._description and other._description:
|
|
198
|
+
self._description = other._description
|
|
199
|
+
if not self._footer and other._footer:
|
|
200
|
+
self._footer = dict(other._footer)
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
# ── Validation ────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def total_chars(self) -> int:
|
|
207
|
+
"""Total character count across all text fields."""
|
|
208
|
+
total = 0
|
|
209
|
+
if self._title:
|
|
210
|
+
total += len(self._title)
|
|
211
|
+
if self._description:
|
|
212
|
+
total += len(self._description)
|
|
213
|
+
for f in self._fields:
|
|
214
|
+
total += len(f.get("name", "")) + len(f.get("value", ""))
|
|
215
|
+
if self._footer:
|
|
216
|
+
total += len(self._footer.get("text", ""))
|
|
217
|
+
if self._author:
|
|
218
|
+
total += len(self._author.get("name", ""))
|
|
219
|
+
return total
|
|
220
|
+
|
|
221
|
+
def is_valid(self) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Check that the embed is within Discord's character and count limits.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if valid, False otherwise.
|
|
227
|
+
"""
|
|
228
|
+
if self._title and len(self._title) > self.LIMITS["title"]:
|
|
229
|
+
return False
|
|
230
|
+
if self._description and len(self._description) > self.LIMITS["description"]:
|
|
231
|
+
return False
|
|
232
|
+
if len(self._fields) > self.LIMITS["fields"]:
|
|
233
|
+
return False
|
|
234
|
+
for f in self._fields:
|
|
235
|
+
if len(f.get("name", "")) > self.LIMITS["field_name"]:
|
|
236
|
+
return False
|
|
237
|
+
if len(f.get("value", "")) > self.LIMITS["field_value"]:
|
|
238
|
+
return False
|
|
239
|
+
if self._footer and len(self._footer.get("text", "")) > self.LIMITS["footer_text"]:
|
|
240
|
+
return False
|
|
241
|
+
if self._author and len(self._author.get("name", "")) > self.LIMITS["author_name"]:
|
|
242
|
+
return False
|
|
243
|
+
if self.total_chars > self.LIMITS["total"]:
|
|
244
|
+
return False
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
def truncate_all(self) -> "Embed":
|
|
248
|
+
"""
|
|
249
|
+
Auto-truncate all text fields to Discord's limits.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
self (chainable)
|
|
253
|
+
"""
|
|
254
|
+
def _cut(text: str, limit: int) -> str:
|
|
255
|
+
return text[: limit - 3] + "..." if len(text) > limit else text
|
|
256
|
+
|
|
257
|
+
if self._title:
|
|
258
|
+
self._title = _cut(self._title, self.LIMITS["title"])
|
|
259
|
+
if self._description:
|
|
260
|
+
self._description = _cut(self._description, self.LIMITS["description"])
|
|
261
|
+
if self._footer and self._footer.get("text"):
|
|
262
|
+
self._footer["text"] = _cut(self._footer["text"], self.LIMITS["footer_text"])
|
|
263
|
+
if self._author and self._author.get("name"):
|
|
264
|
+
self._author["name"] = _cut(self._author["name"], self.LIMITS["author_name"])
|
|
265
|
+
self._fields = self._fields[: self.LIMITS["fields"]]
|
|
266
|
+
for f in self._fields:
|
|
267
|
+
f["name"] = _cut(f.get("name", ""), self.LIMITS["field_name"])
|
|
268
|
+
f["value"] = _cut(f.get("value", ""), self.LIMITS["field_value"])
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str:
|
|
272
|
+
return (
|
|
273
|
+
f"<Embed title={self._title!r} fields={len(self._fields)} "
|
|
274
|
+
f"chars={self.total_chars} valid={self.is_valid()}>"
|
|
275
|
+
)
|