kroxy 1.0.3__tar.gz → 1.0.4__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.3/kroxy.egg-info → kroxy-1.0.4}/PKG-INFO +2 -2
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/__init__.py +18 -1
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/__init__.py +6 -0
- kroxy-1.0.4/kroxy/discord/automod.py +488 -0
- kroxy-1.0.4/kroxy/discord/roles.py +453 -0
- kroxy-1.0.4/kroxy/discord/tickets.py +390 -0
- kroxy-1.0.4/kroxy/website/__init__.py +17 -0
- kroxy-1.0.4/kroxy/website/ratelimit.py +300 -0
- kroxy-1.0.4/kroxy/website/seo.py +451 -0
- {kroxy-1.0.3 → kroxy-1.0.4/kroxy.egg-info}/PKG-INFO +2 -2
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy.egg-info/SOURCES.txt +5 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/pyproject.toml +2 -2
- kroxy-1.0.3/kroxy/website/__init__.py +0 -10
- {kroxy-1.0.3 → kroxy-1.0.4}/LICENSE +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/MANIFEST.in +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/README.md +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/antinuke.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/api.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/cache.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/checkers.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/commands.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/embed.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/giveaway.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/logger.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/moderation.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/music.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/utils.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/discord/webhook.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/website/fetch.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/website/monitor.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/website/scraper.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy/website/utils.py +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy.egg-info/dependency_links.txt +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy.egg-info/requires.txt +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/kroxy.egg-info/top_level.txt +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/setup.cfg +0 -0
- {kroxy-1.0.3 → kroxy-1.0.4}/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.4
|
|
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,webhook,cache,moderation,toolkit
|
|
8
|
+
Keywords: bot,automation,antinuke,giveaway,music,scraper,webhook,cache,moderation,roles,tickets,automod,seo,ratelimit,toolkit
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.9
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -7,7 +7,9 @@ All classes are available directly from the top-level `kroxy` namespace.
|
|
|
7
7
|
|
|
8
8
|
import kroxy
|
|
9
9
|
from kroxy import AntiNuke, Embed, Moderation, Cache, Webhook
|
|
10
|
+
from kroxy import RoleManager, TicketManager, AutoMod
|
|
10
11
|
from kroxy import Fetcher, Scraper, WebUtils, Monitor
|
|
12
|
+
from kroxy import SEOAnalyzer, RateLimiter
|
|
11
13
|
from kroxy import discord # full discord submodule
|
|
12
14
|
from kroxy import website # full website submodule
|
|
13
15
|
|
|
@@ -17,7 +19,7 @@ Proprietary — Copyright (c) 2026 kroxy. All rights reserved.
|
|
|
17
19
|
Unauthorized copying, modification, or redistribution is strictly prohibited.
|
|
18
20
|
"""
|
|
19
21
|
|
|
20
|
-
__version__ = "1.0.
|
|
22
|
+
__version__ = "1.0.4"
|
|
21
23
|
__author__ = "kroxy"
|
|
22
24
|
__license__ = "Proprietary"
|
|
23
25
|
__copyright__ = "Copyright (c) 2026 kroxy. All rights reserved."
|
|
@@ -39,12 +41,17 @@ from kroxy.discord.embed import Embed
|
|
|
39
41
|
from kroxy.discord.logger import ModLogger
|
|
40
42
|
from kroxy.discord.cache import Cache
|
|
41
43
|
from kroxy.discord.webhook import Webhook
|
|
44
|
+
from kroxy.discord.roles import RoleManager, RoleInfo
|
|
45
|
+
from kroxy.discord.tickets import TicketManager, Ticket, TicketNote
|
|
46
|
+
from kroxy.discord.automod import AutoMod, Violation
|
|
42
47
|
|
|
43
48
|
# ── Website ────────────────────────────────────────────────────────────────────
|
|
44
49
|
from kroxy.website.fetch import Fetcher
|
|
45
50
|
from kroxy.website.scraper import Scraper
|
|
46
51
|
from kroxy.website.utils import WebUtils
|
|
47
52
|
from kroxy.website.monitor import Monitor, CheckResult
|
|
53
|
+
from kroxy.website.seo import SEOAnalyzer, SEOReport, SEOIssue
|
|
54
|
+
from kroxy.website.ratelimit import RateLimiter, RateLimitInfo
|
|
48
55
|
|
|
49
56
|
__all__ = [
|
|
50
57
|
# Submodules
|
|
@@ -73,6 +80,12 @@ __all__ = [
|
|
|
73
80
|
"Cache",
|
|
74
81
|
# Discord — Webhook
|
|
75
82
|
"Webhook",
|
|
83
|
+
# Discord — Roles
|
|
84
|
+
"RoleManager", "RoleInfo",
|
|
85
|
+
# Discord — Tickets
|
|
86
|
+
"TicketManager", "Ticket", "TicketNote",
|
|
87
|
+
# Discord — AutoMod
|
|
88
|
+
"AutoMod", "Violation",
|
|
76
89
|
# Website — Fetch
|
|
77
90
|
"Fetcher",
|
|
78
91
|
# Website — Scraper
|
|
@@ -81,4 +94,8 @@ __all__ = [
|
|
|
81
94
|
"WebUtils",
|
|
82
95
|
# Website — Monitor
|
|
83
96
|
"Monitor", "CheckResult",
|
|
97
|
+
# Website — SEO
|
|
98
|
+
"SEOAnalyzer", "SEOReport", "SEOIssue",
|
|
99
|
+
# Website — Rate Limiter
|
|
100
|
+
"RateLimiter", "RateLimitInfo",
|
|
84
101
|
]
|
|
@@ -14,6 +14,9 @@ from kroxy.discord.embed import Embed
|
|
|
14
14
|
from kroxy.discord.logger import ModLogger
|
|
15
15
|
from kroxy.discord.cache import Cache
|
|
16
16
|
from kroxy.discord.webhook import Webhook
|
|
17
|
+
from kroxy.discord.roles import RoleManager, RoleInfo
|
|
18
|
+
from kroxy.discord.tickets import TicketManager, Ticket, TicketNote
|
|
19
|
+
from kroxy.discord.automod import AutoMod, Violation
|
|
17
20
|
|
|
18
21
|
__all__ = [
|
|
19
22
|
"DiscordAPI",
|
|
@@ -28,4 +31,7 @@ __all__ = [
|
|
|
28
31
|
"ModLogger",
|
|
29
32
|
"Cache",
|
|
30
33
|
"Webhook",
|
|
34
|
+
"RoleManager", "RoleInfo",
|
|
35
|
+
"TicketManager", "Ticket", "TicketNote",
|
|
36
|
+
"AutoMod", "Violation",
|
|
31
37
|
]
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kroxy.discord.automod - Discord message auto-moderation filters.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Violation:
|
|
13
|
+
user_id: int
|
|
14
|
+
rule: str
|
|
15
|
+
content: str
|
|
16
|
+
timestamp: float = field(default_factory=time.time)
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> Dict:
|
|
19
|
+
return {
|
|
20
|
+
"user_id": self.user_id,
|
|
21
|
+
"rule": self.rule,
|
|
22
|
+
"content": self.content[:200],
|
|
23
|
+
"timestamp": self.timestamp,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutoMod:
|
|
28
|
+
"""
|
|
29
|
+
Client-side Discord auto-moderation engine.
|
|
30
|
+
|
|
31
|
+
Checks messages against configurable rules:
|
|
32
|
+
- Blocked words and phrases
|
|
33
|
+
- Regex patterns
|
|
34
|
+
- Invite links
|
|
35
|
+
- Excessive caps
|
|
36
|
+
- Mass mentions
|
|
37
|
+
- Emoji spam
|
|
38
|
+
- Repeated messages (spam)
|
|
39
|
+
- Link blacklist / whitelist
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
from kroxy import AutoMod
|
|
43
|
+
|
|
44
|
+
mod = AutoMod()
|
|
45
|
+
mod.add_blocked_word("badword")
|
|
46
|
+
mod.set_caps_threshold(70)
|
|
47
|
+
mod.set_mention_limit(5)
|
|
48
|
+
|
|
49
|
+
result = mod.scan_message(user_id=123, content="HEY @everyone!!!")
|
|
50
|
+
if result["flagged"]:
|
|
51
|
+
print(result["reasons"])
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self._blocked_words: Set[str] = set()
|
|
56
|
+
self._blocked_patterns: List[re.Pattern] = []
|
|
57
|
+
self._link_blacklist: Set[str] = set()
|
|
58
|
+
self._link_whitelist: Set[str] = set()
|
|
59
|
+
self._exempt_roles: Set[int] = set()
|
|
60
|
+
self._exempt_channels: Set[int] = set()
|
|
61
|
+
self._caps_threshold: int = 70
|
|
62
|
+
self._mention_limit: int = 5
|
|
63
|
+
self._emoji_limit: int = 10
|
|
64
|
+
self._spam_window: int = 5
|
|
65
|
+
self._spam_limit: int = 5
|
|
66
|
+
self._violations: List[Violation] = []
|
|
67
|
+
self._recent_messages: Dict[int, List[float]] = {}
|
|
68
|
+
self._invite_pattern = re.compile(
|
|
69
|
+
r"discord(?:\.gg|app\.com/invite|\.com/invite)/[\w-]+", re.IGNORECASE
|
|
70
|
+
)
|
|
71
|
+
self._url_pattern = re.compile(
|
|
72
|
+
r"https?://([a-zA-Z0-9.-]+)", re.IGNORECASE
|
|
73
|
+
)
|
|
74
|
+
self._emoji_pattern = re.compile(
|
|
75
|
+
r"<a?:[a-zA-Z0-9_]+:\d+>|[\U00010000-\U0010ffff]", re.UNICODE
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# ── Blocked words ──────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def add_blocked_word(self, word: str) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Add a word or phrase to the block list (case-insensitive).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
word: Word or phrase to block.
|
|
86
|
+
"""
|
|
87
|
+
self._blocked_words.add(word.lower().strip())
|
|
88
|
+
|
|
89
|
+
def remove_blocked_word(self, word: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Remove a word from the block list.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if removed, False if it wasn't in the list.
|
|
95
|
+
"""
|
|
96
|
+
word = word.lower().strip()
|
|
97
|
+
if word in self._blocked_words:
|
|
98
|
+
self._blocked_words.discard(word)
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def add_blocked_words(self, words: List[str]) -> None:
|
|
103
|
+
"""Add multiple words to the block list at once."""
|
|
104
|
+
for word in words:
|
|
105
|
+
self.add_blocked_word(word)
|
|
106
|
+
|
|
107
|
+
def clear_blocked_words(self) -> None:
|
|
108
|
+
"""Remove all blocked words."""
|
|
109
|
+
self._blocked_words.clear()
|
|
110
|
+
|
|
111
|
+
def check_blocked_words(self, content: str) -> List[str]:
|
|
112
|
+
"""
|
|
113
|
+
Check a message for blocked words.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of matched blocked words (empty if none found).
|
|
117
|
+
"""
|
|
118
|
+
lower = content.lower()
|
|
119
|
+
return [w for w in self._blocked_words if w in lower]
|
|
120
|
+
|
|
121
|
+
# ── Regex patterns ─────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def add_blocked_pattern(self, pattern: str, flags: int = re.IGNORECASE) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Add a regex pattern to the block list.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
pattern: Regex pattern string.
|
|
129
|
+
flags: Regex flags (default: re.IGNORECASE).
|
|
130
|
+
"""
|
|
131
|
+
self._blocked_patterns.append(re.compile(pattern, flags))
|
|
132
|
+
|
|
133
|
+
def clear_blocked_patterns(self) -> None:
|
|
134
|
+
"""Remove all blocked regex patterns."""
|
|
135
|
+
self._blocked_patterns.clear()
|
|
136
|
+
|
|
137
|
+
def check_patterns(self, content: str) -> List[str]:
|
|
138
|
+
"""
|
|
139
|
+
Check a message against all blocked regex patterns.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of matched pattern strings (empty if none found).
|
|
143
|
+
"""
|
|
144
|
+
matched = []
|
|
145
|
+
for pattern in self._blocked_patterns:
|
|
146
|
+
if pattern.search(content):
|
|
147
|
+
matched.append(pattern.pattern)
|
|
148
|
+
return matched
|
|
149
|
+
|
|
150
|
+
# ── Invite links ───────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def is_invite_link(self, content: str) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Check if a message contains a Discord invite link.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True if an invite link is detected.
|
|
158
|
+
"""
|
|
159
|
+
return bool(self._invite_pattern.search(content))
|
|
160
|
+
|
|
161
|
+
def strip_invites(self, content: str) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Remove Discord invite links from a message.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Cleaned message string.
|
|
167
|
+
"""
|
|
168
|
+
return self._invite_pattern.sub("[invite removed]", content)
|
|
169
|
+
|
|
170
|
+
# ── Caps check ─────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def set_caps_threshold(self, percent: int) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Set the uppercase-character percentage that triggers the caps filter.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
percent: Integer 0–100. Default is 70.
|
|
178
|
+
"""
|
|
179
|
+
self._caps_threshold = max(0, min(100, percent))
|
|
180
|
+
|
|
181
|
+
def check_caps(self, content: str, min_length: int = 8) -> bool:
|
|
182
|
+
"""
|
|
183
|
+
Check if a message exceeds the caps threshold.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
content: Message text.
|
|
187
|
+
min_length: Minimum message length to apply the rule (default 8).
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if the message is flagged for excessive caps.
|
|
191
|
+
"""
|
|
192
|
+
letters = [c for c in content if c.isalpha()]
|
|
193
|
+
if len(letters) < min_length:
|
|
194
|
+
return False
|
|
195
|
+
upper_pct = (sum(1 for c in letters if c.isupper()) / len(letters)) * 100
|
|
196
|
+
return upper_pct >= self._caps_threshold
|
|
197
|
+
|
|
198
|
+
def caps_percentage(self, content: str) -> float:
|
|
199
|
+
"""
|
|
200
|
+
Calculate the uppercase percentage of a message.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Float 0.0–100.0, or 0.0 if no alphabetic characters.
|
|
204
|
+
"""
|
|
205
|
+
letters = [c for c in content if c.isalpha()]
|
|
206
|
+
if not letters:
|
|
207
|
+
return 0.0
|
|
208
|
+
return round((sum(1 for c in letters if c.isupper()) / len(letters)) * 100, 1)
|
|
209
|
+
|
|
210
|
+
# ── Mention spam ───────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def set_mention_limit(self, limit: int) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Set the maximum number of unique @mentions allowed per message.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
limit: Maximum mention count. Default is 5.
|
|
218
|
+
"""
|
|
219
|
+
self._mention_limit = max(1, limit)
|
|
220
|
+
|
|
221
|
+
def count_mentions(self, content: str) -> int:
|
|
222
|
+
"""
|
|
223
|
+
Count the number of @user and @role mentions in a message.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Integer mention count.
|
|
227
|
+
"""
|
|
228
|
+
return len(re.findall(r"<@[!&]?\d+>|@everyone|@here", content))
|
|
229
|
+
|
|
230
|
+
def check_mentions(self, content: str) -> bool:
|
|
231
|
+
"""
|
|
232
|
+
Check if a message exceeds the mention limit.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if flagged for mass mentions.
|
|
236
|
+
"""
|
|
237
|
+
return self.count_mentions(content) > self._mention_limit
|
|
238
|
+
|
|
239
|
+
# ── Emoji spam ─────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def set_emoji_limit(self, limit: int) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Set the maximum number of emojis allowed per message.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
limit: Maximum emoji count. Default is 10.
|
|
247
|
+
"""
|
|
248
|
+
self._emoji_limit = max(1, limit)
|
|
249
|
+
|
|
250
|
+
def count_emojis(self, content: str) -> int:
|
|
251
|
+
"""
|
|
252
|
+
Count the number of emojis (custom + Unicode) in a message.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Integer emoji count.
|
|
256
|
+
"""
|
|
257
|
+
return len(self._emoji_pattern.findall(content))
|
|
258
|
+
|
|
259
|
+
def check_emojis(self, content: str) -> bool:
|
|
260
|
+
"""
|
|
261
|
+
Check if a message exceeds the emoji limit.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True if flagged for emoji spam.
|
|
265
|
+
"""
|
|
266
|
+
return self.count_emojis(content) > self._emoji_limit
|
|
267
|
+
|
|
268
|
+
# ── Message spam ───────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def set_spam_threshold(self, messages: int, window: int = 5) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Set the spam detection threshold.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
messages: Max messages allowed within the time window.
|
|
276
|
+
window: Time window in seconds (default 5).
|
|
277
|
+
"""
|
|
278
|
+
self._spam_limit = max(1, messages)
|
|
279
|
+
self._spam_window = max(1, window)
|
|
280
|
+
|
|
281
|
+
def check_spam(self, user_id: int) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Track a message from a user and detect if they are spamming.
|
|
284
|
+
|
|
285
|
+
Call this once per message received from the user.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if the user is sending messages faster than the spam threshold.
|
|
289
|
+
"""
|
|
290
|
+
now = time.time()
|
|
291
|
+
if user_id not in self._recent_messages:
|
|
292
|
+
self._recent_messages[user_id] = []
|
|
293
|
+
self._recent_messages[user_id] = [
|
|
294
|
+
t for t in self._recent_messages[user_id] if now - t < self._spam_window
|
|
295
|
+
]
|
|
296
|
+
self._recent_messages[user_id].append(now)
|
|
297
|
+
return len(self._recent_messages[user_id]) > self._spam_limit
|
|
298
|
+
|
|
299
|
+
def reset_spam_counter(self, user_id: int) -> None:
|
|
300
|
+
"""Reset the spam counter for a specific user."""
|
|
301
|
+
self._recent_messages.pop(user_id, None)
|
|
302
|
+
|
|
303
|
+
# ── Link filtering ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def add_link_blacklist(self, domain: str) -> None:
|
|
306
|
+
"""Add a domain to the link blacklist (e.g. 'phishing.com')."""
|
|
307
|
+
self._link_blacklist.add(domain.lower().strip())
|
|
308
|
+
|
|
309
|
+
def add_link_whitelist(self, domain: str) -> None:
|
|
310
|
+
"""Add a domain to the link whitelist (always allowed)."""
|
|
311
|
+
self._link_whitelist.add(domain.lower().strip())
|
|
312
|
+
|
|
313
|
+
def check_links(self, content: str) -> List[str]:
|
|
314
|
+
"""
|
|
315
|
+
Check a message for blacklisted domains.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
List of matched blacklisted domains found in the message.
|
|
319
|
+
"""
|
|
320
|
+
domains = [m.group(1).lower() for m in self._url_pattern.finditer(content)]
|
|
321
|
+
flagged = []
|
|
322
|
+
for domain in domains:
|
|
323
|
+
if domain in self._whitelist_check(domain):
|
|
324
|
+
continue
|
|
325
|
+
for blocked in self._link_blacklist:
|
|
326
|
+
if domain.endswith(blocked) and domain not in self._link_whitelist:
|
|
327
|
+
flagged.append(domain)
|
|
328
|
+
break
|
|
329
|
+
return flagged
|
|
330
|
+
|
|
331
|
+
def _whitelist_check(self, domain: str) -> Set[str]:
|
|
332
|
+
return self._link_whitelist if domain in self._link_whitelist else set()
|
|
333
|
+
|
|
334
|
+
# ── Exempt roles / channels ────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def add_exempt_role(self, role_id: int) -> None:
|
|
337
|
+
"""Mark a role as exempt from all AutoMod checks."""
|
|
338
|
+
self._exempt_roles.add(role_id)
|
|
339
|
+
|
|
340
|
+
def remove_exempt_role(self, role_id: int) -> None:
|
|
341
|
+
"""Remove a role from the exempt list."""
|
|
342
|
+
self._exempt_roles.discard(role_id)
|
|
343
|
+
|
|
344
|
+
def add_exempt_channel(self, channel_id: int) -> None:
|
|
345
|
+
"""Mark a channel as exempt from all AutoMod checks."""
|
|
346
|
+
self._exempt_channels.add(channel_id)
|
|
347
|
+
|
|
348
|
+
def remove_exempt_channel(self, channel_id: int) -> None:
|
|
349
|
+
"""Remove a channel from the exempt list."""
|
|
350
|
+
self._exempt_channels.discard(channel_id)
|
|
351
|
+
|
|
352
|
+
def is_exempt(self, channel_id: int = None, role_ids: List[int] = None) -> bool:
|
|
353
|
+
"""
|
|
354
|
+
Check if a channel or any of the provided role IDs are exempt.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
True if the message source is exempt from AutoMod.
|
|
358
|
+
"""
|
|
359
|
+
if channel_id and channel_id in self._exempt_channels:
|
|
360
|
+
return True
|
|
361
|
+
if role_ids:
|
|
362
|
+
for rid in role_ids:
|
|
363
|
+
if rid in self._exempt_roles:
|
|
364
|
+
return True
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
# ── Violation log ──────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
def _log_violation(self, user_id: int, rule: str, content: str) -> None:
|
|
370
|
+
self._violations.append(Violation(user_id=user_id, rule=rule, content=content))
|
|
371
|
+
if len(self._violations) > 1000:
|
|
372
|
+
self._violations = self._violations[-1000:]
|
|
373
|
+
|
|
374
|
+
def get_violations(self, user_id: int = None) -> List[Violation]:
|
|
375
|
+
"""
|
|
376
|
+
Get the violation log.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
user_id: Filter by specific user (optional).
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
List of Violation objects.
|
|
383
|
+
"""
|
|
384
|
+
if user_id is not None:
|
|
385
|
+
return [v for v in self._violations if v.user_id == user_id]
|
|
386
|
+
return list(self._violations)
|
|
387
|
+
|
|
388
|
+
def clear_violations(self, user_id: int = None) -> int:
|
|
389
|
+
"""
|
|
390
|
+
Clear violations from the log.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
user_id: If provided, clear only violations for this user.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Number of violations removed.
|
|
397
|
+
"""
|
|
398
|
+
if user_id is not None:
|
|
399
|
+
before = len(self._violations)
|
|
400
|
+
self._violations = [v for v in self._violations if v.user_id != user_id]
|
|
401
|
+
return before - len(self._violations)
|
|
402
|
+
count = len(self._violations)
|
|
403
|
+
self._violations.clear()
|
|
404
|
+
return count
|
|
405
|
+
|
|
406
|
+
def violation_count(self, user_id: int) -> int:
|
|
407
|
+
"""Return how many violations a user has logged."""
|
|
408
|
+
return sum(1 for v in self._violations if v.user_id == user_id)
|
|
409
|
+
|
|
410
|
+
# ── All-in-one scanner ─────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def scan_message(
|
|
413
|
+
self,
|
|
414
|
+
user_id: int,
|
|
415
|
+
content: str,
|
|
416
|
+
channel_id: int = None,
|
|
417
|
+
role_ids: List[int] = None,
|
|
418
|
+
check_spam: bool = True,
|
|
419
|
+
) -> Dict:
|
|
420
|
+
"""
|
|
421
|
+
Run all AutoMod checks on a message in one call.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
user_id: Author's Discord user ID.
|
|
425
|
+
content: Message content string.
|
|
426
|
+
channel_id: Channel ID (for exemption checks).
|
|
427
|
+
role_ids: Author's role IDs (for exemption checks).
|
|
428
|
+
check_spam: Whether to run the message-rate spam check.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Dict with:
|
|
432
|
+
- flagged (bool): True if any rule was violated.
|
|
433
|
+
- reasons (List[str]): List of rule names that triggered.
|
|
434
|
+
- details (Dict): Extra detail per rule.
|
|
435
|
+
"""
|
|
436
|
+
if self.is_exempt(channel_id=channel_id, role_ids=role_ids):
|
|
437
|
+
return {"flagged": False, "reasons": [], "details": {}, "exempt": True}
|
|
438
|
+
|
|
439
|
+
reasons = []
|
|
440
|
+
details: Dict = {}
|
|
441
|
+
|
|
442
|
+
blocked = self.check_blocked_words(content)
|
|
443
|
+
if blocked:
|
|
444
|
+
reasons.append("blocked_word")
|
|
445
|
+
details["blocked_words"] = blocked
|
|
446
|
+
self._log_violation(user_id, "blocked_word", content)
|
|
447
|
+
|
|
448
|
+
patterns = self.check_patterns(content)
|
|
449
|
+
if patterns:
|
|
450
|
+
reasons.append("blocked_pattern")
|
|
451
|
+
details["patterns"] = patterns
|
|
452
|
+
self._log_violation(user_id, "blocked_pattern", content)
|
|
453
|
+
|
|
454
|
+
if self.is_invite_link(content):
|
|
455
|
+
reasons.append("invite_link")
|
|
456
|
+
self._log_violation(user_id, "invite_link", content)
|
|
457
|
+
|
|
458
|
+
if self.check_caps(content):
|
|
459
|
+
reasons.append("excessive_caps")
|
|
460
|
+
details["caps_pct"] = self.caps_percentage(content)
|
|
461
|
+
self._log_violation(user_id, "excessive_caps", content)
|
|
462
|
+
|
|
463
|
+
if self.check_mentions(content):
|
|
464
|
+
reasons.append("mass_mention")
|
|
465
|
+
details["mention_count"] = self.count_mentions(content)
|
|
466
|
+
self._log_violation(user_id, "mass_mention", content)
|
|
467
|
+
|
|
468
|
+
if self.check_emojis(content):
|
|
469
|
+
reasons.append("emoji_spam")
|
|
470
|
+
details["emoji_count"] = self.count_emojis(content)
|
|
471
|
+
self._log_violation(user_id, "emoji_spam", content)
|
|
472
|
+
|
|
473
|
+
bad_links = self.check_links(content)
|
|
474
|
+
if bad_links:
|
|
475
|
+
reasons.append("blacklisted_link")
|
|
476
|
+
details["domains"] = bad_links
|
|
477
|
+
self._log_violation(user_id, "blacklisted_link", content)
|
|
478
|
+
|
|
479
|
+
if check_spam and self.check_spam(user_id):
|
|
480
|
+
reasons.append("message_spam")
|
|
481
|
+
self._log_violation(user_id, "message_spam", content)
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"flagged": bool(reasons),
|
|
485
|
+
"reasons": reasons,
|
|
486
|
+
"details": details,
|
|
487
|
+
"exempt": False,
|
|
488
|
+
}
|