feat: SelfbotGuard v1.2.0 — 14 heuristic layers, no exceptions
Added 8 new detection heuristics (7-14): 7. Cross-channel spam — same msg in 3+ channels within 5s 8. Cross-server simultaneous activity — msgs in different guilds <2s apart 9. Edit cadence — precise, consistent message edit timing 10. Presence anomalies — sending messages while offline/invisible 11. Reaction sniping — adding reactions within <500ms 12. Formatting analysis — clean structured output at inhuman speed 13. Token-snipe triggers — instant response to giveaway/nitro/etc. 14. Self-delete patterns — send → wait exact N seconds → delete New event listeners: on_message_edit, on_reaction_add, on_message_delete Updated tracking structures and scan command output
This commit is contained in:
parent
ebafe6567d
commit
dfff1c5b3e
2 changed files with 413 additions and 5 deletions
|
|
@ -2,11 +2,11 @@
|
||||||
"author": [
|
"author": [
|
||||||
"everestmcarthur"
|
"everestmcarthur"
|
||||||
],
|
],
|
||||||
"install_msg": "Thanks for installing **SelfbotGuard v1.0.0** \u2014 advanced selfbot detection & punishment.\n\nGet started: `[p]sbguard enable` to turn it on, `[p]sbguard action <action>` to set punishment.\n\nDetection heuristics: rich-embed abuse, inhuman response speed, timing precision analysis, 24/7 activity profiling, and automated command-response pattern matching.\n\nCog is *disabled by default* \u2014 enable per-server.",
|
"install_msg": "Thanks for installing **SelfbotGuard v1.2.0** \u2014 14-layer selfbot detection & punishment.\n\nGet started: `[p]sbguard enable` to turn it on, `[p]sbguard action <action>` to set punishment.\n\nDetection: rich-embed abuse, inhuman response speed, timing precision, 24/7 activity, pattern matching, burst detection, cross-channel spam, cross-server simultaneous activity, edit cadence, presence anomalies, reaction sniping, formatting analysis, trigger-word sniping, and self-delete patterns.\n\nCog is *disabled by default* \u2014 enable per-server.",
|
||||||
"name": "SelfbotGuard",
|
"name": "SelfbotGuard",
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"short": "Advanced selfbot detection with configurable per-server punishments.",
|
"short": "Advanced selfbot detection with configurable per-server punishments.",
|
||||||
"description": "SelfbotGuard v1.0.0 detects Discord selfbots using multiple heuristic layers: rich-embed abuse detection (user accounts cannot send rich embeds via the client), inhuman response-time analysis, message-timing precision scoring, 24/7 activity profiling, automated command-response pattern matching, and rapid-fire burst detection. Each heuristic contributes to a cumulative suspicion score per user. When the score exceeds a configurable threshold, the bot takes action: warn, mute/timeout, kick, ban, or log-only. Admins can configure sensitivity, exempt roles/users, set a dedicated log channel, and review flagged users. Cog is disabled by default per-server.",
|
"description": "SelfbotGuard v1.2.0 detects Discord selfbots using 14 heuristic layers: rich-embed abuse, inhuman response-time analysis, message-timing precision, 24/7 activity profiling, automated command-response patterns, rapid-fire burst detection, cross-channel spam (same message blasted across channels), cross-server simultaneous activity (messages in different servers within impossible windows), edit cadence analysis, presence anomalies (messaging while offline/invisible), reaction speed sniping, suspiciously clean formatting at speed, trigger-word sniping (giveaway/nitro/etc.), and self-delete patterns. Each heuristic contributes to a cumulative suspicion score per user. When the score exceeds a configurable threshold, the bot takes action: warn, mute/timeout, kick, ban, or log-only. No exceptions.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"selfbot",
|
"selfbot",
|
||||||
"detection",
|
"detection",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ Detection heuristics:
|
||||||
4. Activity profiling — 24/7 activity across all hours without gaps.
|
4. Activity profiling — 24/7 activity across all hours without gaps.
|
||||||
5. Pattern matching — Automated prefix→response command patterns.
|
5. Pattern matching — Automated prefix→response command patterns.
|
||||||
6. Burst detection — Rapid-fire messages exceeding human typing speed.
|
6. Burst detection — Rapid-fire messages exceeding human typing speed.
|
||||||
|
7. Cross-channel spam — Same message in multiple channels simultaneously.
|
||||||
|
8. Cross-server activity — Messages in different servers within impossible windows.
|
||||||
|
9. Edit cadence — Precise, consistent message edit timing.
|
||||||
|
10. Presence anomalies — Sending messages while offline/invisible.
|
||||||
|
11. Reaction sniping — Adding reactions within milliseconds of a post.
|
||||||
|
12. Formatting analysis — Suspiciously clean structured output at inhuman speed.
|
||||||
|
13. Token-snipe triggers — Instantly responding to specific trigger words.
|
||||||
|
14. Self-delete patterns — Send → wait exact N seconds → delete.
|
||||||
|
|
||||||
Each heuristic contributes to a cumulative suspicion score. When the score
|
Each heuristic contributes to a cumulative suspicion score. When the score
|
||||||
exceeds the server's threshold, the configured action is taken.
|
exceeds the server's threshold, the configured action is taken.
|
||||||
|
|
@ -43,6 +51,14 @@ WEIGHT_TIMING_PRECISION = 20 # Consistent intervals
|
||||||
WEIGHT_ACTIVITY_247 = 25 # Active across all hour buckets
|
WEIGHT_ACTIVITY_247 = 25 # Active across all hour buckets
|
||||||
WEIGHT_PATTERN_MATCH = 15 # Command-response patterns
|
WEIGHT_PATTERN_MATCH = 15 # Command-response patterns
|
||||||
WEIGHT_BURST = 10 # Rapid-fire messages
|
WEIGHT_BURST = 10 # Rapid-fire messages
|
||||||
|
WEIGHT_CROSS_CHANNEL = 35 # Same message blasted across channels
|
||||||
|
WEIGHT_CROSS_SERVER = 30 # Active in multiple guilds simultaneously
|
||||||
|
WEIGHT_EDIT_CADENCE = 20 # Precise edit timing
|
||||||
|
WEIGHT_PRESENCE_ANOMALY = 25 # Messaging while offline/invisible
|
||||||
|
WEIGHT_REACTION_SNIPE = 20 # Instant reactions
|
||||||
|
WEIGHT_CLEAN_FORMAT = 15 # Suspiciously clean output at speed
|
||||||
|
WEIGHT_TOKEN_SNIPE = 30 # Instant response to trigger words
|
||||||
|
WEIGHT_SELF_DELETE = 25 # Timed self-deletion patterns
|
||||||
|
|
||||||
# Thresholds for heuristics
|
# Thresholds for heuristics
|
||||||
RESPONSE_TIME_FLOOR_MS = 300 # Faster than this = suspicious (ms)
|
RESPONSE_TIME_FLOOR_MS = 300 # Faster than this = suspicious (ms)
|
||||||
|
|
@ -53,6 +69,26 @@ ACTIVITY_HOUR_COVERAGE = 20 # Out of 24 hours — if active in this many, su
|
||||||
MIN_MESSAGES_FOR_TIMING = 10 # Need this many messages before timing analysis
|
MIN_MESSAGES_FOR_TIMING = 10 # Need this many messages before timing analysis
|
||||||
MIN_RESPONSES_FOR_SPEED = 5 # Need this many fast responses before flagging
|
MIN_RESPONSES_FOR_SPEED = 5 # Need this many fast responses before flagging
|
||||||
PATTERN_REPEAT_THRESHOLD= 5 # Same prefix→response pattern this many times
|
PATTERN_REPEAT_THRESHOLD= 5 # Same prefix→response pattern this many times
|
||||||
|
CROSS_CHAN_WINDOW_SECS = 5 # Window for cross-channel spam detection
|
||||||
|
CROSS_CHAN_MIN_CHANNELS = 3 # Same msg in this many channels = selfbot
|
||||||
|
CROSS_CHAN_SIMILARITY = 0.85 # Content similarity threshold (0-1)
|
||||||
|
CROSS_SERVER_WINDOW_SECS= 2 # Window for cross-server simultaneous activity
|
||||||
|
CROSS_SERVER_MIN_HITS = 3 # This many near-simultaneous events to flag
|
||||||
|
MAX_CROSS_CHAN_HISTORY = 50 # Rolling window of cross-channel entries per user
|
||||||
|
EDIT_VARIANCE_CEIL_MS = 100 # Edit delay variance below this = suspicious
|
||||||
|
MIN_EDITS_FOR_CADENCE = 5 # Need this many edits before analysing cadence
|
||||||
|
REACTION_SPEED_CEIL_MS = 500 # Reaction faster than this = suspicious
|
||||||
|
MIN_FAST_REACTIONS = 3 # This many fast reactions before flagging
|
||||||
|
CLEAN_FORMAT_MIN_LEN = 200 # Minimum message length for formatting analysis
|
||||||
|
CLEAN_FORMAT_SPEED_CEIL = 5.0 # Seconds — structured msg this fast = suspicious
|
||||||
|
TRIGGER_WORDS = { # Common selfbot trigger words (lowercase)
|
||||||
|
"giveaway", "nitro", "drop", "free", "claim", "airdrop",
|
||||||
|
"discord.gift", "discord.gg", "dank memer", "pokétwo",
|
||||||
|
}
|
||||||
|
TRIGGER_RESPONSE_CEIL_MS= 1000 # Respond to trigger within this = suspicious
|
||||||
|
MIN_TRIGGER_HITS = 3 # This many trigger-responses before flagging
|
||||||
|
SELF_DELETE_VARIANCE_MS = 200 # Variance of send→delete intervals below this = bot
|
||||||
|
MIN_SELF_DELETES = 3 # This many patterned self-deletes before flagging
|
||||||
|
|
||||||
# Data retention
|
# Data retention
|
||||||
MAX_TIMESTAMPS_PER_USER = 200 # Rolling window of message timestamps
|
MAX_TIMESTAMPS_PER_USER = 200 # Rolling window of message timestamps
|
||||||
|
|
@ -87,6 +123,9 @@ class UserProfile:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"user_id", "guild_id", "timestamps", "response_times_ms",
|
"user_id", "guild_id", "timestamps", "response_times_ms",
|
||||||
"hour_buckets", "patterns", "bursts", "embed_strikes",
|
"hour_buckets", "patterns", "bursts", "embed_strikes",
|
||||||
|
"cross_channel_hits", "cross_server_hits",
|
||||||
|
"edit_delays_ms", "presence_anomalies", "fast_reactions",
|
||||||
|
"trigger_responses", "self_delete_intervals",
|
||||||
"score", "last_scored", "last_message_ts", "flagged",
|
"score", "last_scored", "last_message_ts", "flagged",
|
||||||
"action_taken", "last_active",
|
"action_taken", "last_active",
|
||||||
)
|
)
|
||||||
|
|
@ -100,6 +139,13 @@ class UserProfile:
|
||||||
self.patterns: Dict[str, int] = defaultdict(int) # "prefix→response" counters
|
self.patterns: Dict[str, int] = defaultdict(int) # "prefix→response" counters
|
||||||
self.bursts: int = 0 # Number of burst events detected
|
self.bursts: int = 0 # Number of burst events detected
|
||||||
self.embed_strikes: int = 0 # Rich embeds sent by non-bot user
|
self.embed_strikes: int = 0 # Rich embeds sent by non-bot user
|
||||||
|
self.cross_channel_hits: int = 0 # Cross-channel spam events
|
||||||
|
self.cross_server_hits: int = 0 # Cross-server simultaneous events
|
||||||
|
self.edit_delays_ms: Deque[float] = deque(maxlen=50) # Message edit delays
|
||||||
|
self.presence_anomalies: int = 0 # Msgs while offline/invisible
|
||||||
|
self.fast_reactions: int = 0 # Reactions added inhumanly fast
|
||||||
|
self.trigger_responses: int = 0 # Instant responses to trigger words
|
||||||
|
self.self_delete_intervals: Deque[float] = deque(maxlen=50) # Send→delete intervals
|
||||||
self.score: float = 0.0
|
self.score: float = 0.0
|
||||||
self.last_scored: float = 0.0 # time.time() of last score calc
|
self.last_scored: float = 0.0 # time.time() of last score calc
|
||||||
self.last_message_ts: float = 0.0 # Timestamp of user's last message
|
self.last_message_ts: float = 0.0 # Timestamp of user's last message
|
||||||
|
|
@ -128,7 +174,7 @@ class UserProfile:
|
||||||
class SelfbotGuard(commands.Cog):
|
class SelfbotGuard(commands.Cog):
|
||||||
"""Advanced selfbot detection with per-server configurable punishments."""
|
"""Advanced selfbot detection with per-server configurable punishments."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.2.0"
|
||||||
__author__ = ["everestmcarthur"]
|
__author__ = ["everestmcarthur"]
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
def __init__(self, bot: Red):
|
||||||
|
|
@ -157,6 +203,25 @@ class SelfbotGuard(commands.Cog):
|
||||||
# guild_id -> channel_id -> (author_id, timestamp, content_prefix)
|
# guild_id -> channel_id -> (author_id, timestamp, content_prefix)
|
||||||
self._recent_messages: Dict[int, Dict[int, Tuple[int, float, str]]] = defaultdict(dict)
|
self._recent_messages: Dict[int, Dict[int, Tuple[int, float, str]]] = defaultdict(dict)
|
||||||
|
|
||||||
|
# Cross-channel tracking: guild_id -> user_id -> deque of (channel_id, timestamp, content_hash)
|
||||||
|
self._cross_channel: Dict[int, Dict[int, Deque[Tuple[int, float, str]]]] = defaultdict(
|
||||||
|
lambda: defaultdict(lambda: deque(maxlen=MAX_CROSS_CHAN_HISTORY))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cross-server tracking (GLOBAL, not per-guild):
|
||||||
|
# user_id -> deque of (guild_id, timestamp)
|
||||||
|
self._global_activity: Dict[int, Deque[Tuple[int, float]]] = defaultdict(
|
||||||
|
lambda: deque(maxlen=100)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Message send times for self-delete correlation:
|
||||||
|
# message_id -> (author_id, guild_id, send_timestamp)
|
||||||
|
self._message_send_times: Dict[int, Tuple[int, int, float]] = {}
|
||||||
|
|
||||||
|
# Recent trigger messages per channel for token-snipe detection:
|
||||||
|
# channel_id -> (trigger_word, timestamp)
|
||||||
|
self._trigger_cache: Dict[int, Tuple[str, float]] = {}
|
||||||
|
|
||||||
# Background task
|
# Background task
|
||||||
self._prune_task: Optional[asyncio.Task] = None
|
self._prune_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
|
@ -352,6 +417,201 @@ class SelfbotGuard(commands.Cog):
|
||||||
return WEIGHT_BURST * profile.bursts
|
return WEIGHT_BURST * profile.bursts
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _content_hash(text: str) -> str:
|
||||||
|
"""Normalise and hash message content for cross-channel comparison."""
|
||||||
|
# Strip whitespace, lower-case, remove common prefixes like bot commands
|
||||||
|
normalised = text.strip().lower()
|
||||||
|
# Use first 100 chars as the fingerprint — enough to detect copy-paste
|
||||||
|
return normalised[:100]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _content_similar(a: str, b: str) -> float:
|
||||||
|
"""Quick similarity ratio between two content hashes (0.0 – 1.0)."""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
# Character-level Jaccard similarity (fast, no imports)
|
||||||
|
set_a, set_b = set(a), set(b)
|
||||||
|
intersection = len(set_a & set_b)
|
||||||
|
union = len(set_a | set_b)
|
||||||
|
return intersection / union if union else 0.0
|
||||||
|
|
||||||
|
def _check_cross_channel(self, message: discord.Message, profile: UserProfile) -> float:
|
||||||
|
"""
|
||||||
|
Heuristic 7: Cross-channel spam.
|
||||||
|
|
||||||
|
Selfbots blast the same (or near-identical) message across multiple
|
||||||
|
channels simultaneously. A human can't paste the same message into
|
||||||
|
3+ channels within 5 seconds. No exceptions.
|
||||||
|
"""
|
||||||
|
content = (message.content or "").strip()
|
||||||
|
if len(content) < 5:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
guild_id = message.guild.id
|
||||||
|
user_id = message.author.id
|
||||||
|
channel_id = message.channel.id
|
||||||
|
now = time.time()
|
||||||
|
content_fp = self._content_hash(content)
|
||||||
|
|
||||||
|
# Record this message
|
||||||
|
user_cache = self._cross_channel[guild_id][user_id]
|
||||||
|
user_cache.append((channel_id, now, content_fp))
|
||||||
|
|
||||||
|
# Look back within the time window for similar content in OTHER channels
|
||||||
|
cutoff = now - CROSS_CHAN_WINDOW_SECS
|
||||||
|
channels_hit: Set[int] = set()
|
||||||
|
for ch_id, ts, fp in user_cache:
|
||||||
|
if ts >= cutoff and ch_id != channel_id:
|
||||||
|
if self._content_similar(content_fp, fp) >= CROSS_CHAN_SIMILARITY:
|
||||||
|
channels_hit.add(ch_id)
|
||||||
|
|
||||||
|
if len(channels_hit) >= (CROSS_CHAN_MIN_CHANNELS - 1): # -1 because current channel counts
|
||||||
|
profile.cross_channel_hits += 1
|
||||||
|
channel_count = len(channels_hit) + 1 # Include current channel
|
||||||
|
# Scale by how many channels — 3 channels is baseline, more = worse
|
||||||
|
multiplier = channel_count / CROSS_CHAN_MIN_CHANNELS
|
||||||
|
score = WEIGHT_CROSS_CHANNEL * multiplier * profile.cross_channel_hits
|
||||||
|
log.debug(
|
||||||
|
"Cross-channel spam: user %s in guild %s — %d channels in %ds (hit #%d)",
|
||||||
|
user_id, guild_id, channel_count, CROSS_CHAN_WINDOW_SECS,
|
||||||
|
profile.cross_channel_hits,
|
||||||
|
)
|
||||||
|
return score
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _check_cross_server(self, message: discord.Message, profile: UserProfile) -> float:
|
||||||
|
"""
|
||||||
|
Heuristic 8: Cross-server simultaneous activity.
|
||||||
|
|
||||||
|
The bot tracks user activity GLOBALLY across all guilds it monitors.
|
||||||
|
If the same user sends messages in different servers within <2 seconds,
|
||||||
|
repeatedly, that's physically impossible for a human (they'd need to
|
||||||
|
switch servers, find a channel, type, and send). No exceptions.
|
||||||
|
"""
|
||||||
|
user_id = message.author.id
|
||||||
|
guild_id = message.guild.id
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Record to global tracker
|
||||||
|
self._global_activity[user_id].append((guild_id, now))
|
||||||
|
|
||||||
|
# Check for messages in OTHER guilds within the window
|
||||||
|
cutoff = now - CROSS_SERVER_WINDOW_SECS
|
||||||
|
other_guilds: Set[int] = set()
|
||||||
|
for g_id, ts in self._global_activity[user_id]:
|
||||||
|
if ts >= cutoff and g_id != guild_id:
|
||||||
|
other_guilds.add(g_id)
|
||||||
|
|
||||||
|
if other_guilds:
|
||||||
|
profile.cross_server_hits += 1
|
||||||
|
if profile.cross_server_hits >= CROSS_SERVER_MIN_HITS:
|
||||||
|
server_count = len(other_guilds) + 1 # Include current
|
||||||
|
multiplier = server_count # More servers = more suspicious
|
||||||
|
score = WEIGHT_CROSS_SERVER * multiplier * (profile.cross_server_hits / CROSS_SERVER_MIN_HITS)
|
||||||
|
log.debug(
|
||||||
|
"Cross-server activity: user %s — %d guilds within %ds (hit #%d)",
|
||||||
|
user_id, server_count, CROSS_SERVER_WINDOW_SECS,
|
||||||
|
profile.cross_server_hits,
|
||||||
|
)
|
||||||
|
return score
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _check_presence_anomaly(self, message: discord.Message, profile: UserProfile) -> float:
|
||||||
|
"""
|
||||||
|
Heuristic 10: Presence anomaly.
|
||||||
|
|
||||||
|
If a user is sending messages but their Discord status is offline or
|
||||||
|
invisible, real clients always update presence. Selfbot libraries
|
||||||
|
(discord.py-self, etc.) often skip presence or leave it static.
|
||||||
|
"""
|
||||||
|
member = message.author
|
||||||
|
if not isinstance(member, discord.Member):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# offline or invisible while sending messages = suspicious
|
||||||
|
if member.status in (discord.Status.offline, discord.Status.invisible):
|
||||||
|
profile.presence_anomalies += 1
|
||||||
|
if profile.presence_anomalies >= 3:
|
||||||
|
return WEIGHT_PRESENCE_ANOMALY * (profile.presence_anomalies / 3)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _check_clean_formatting(self, message: discord.Message, profile: UserProfile) -> float:
|
||||||
|
"""
|
||||||
|
Heuristic 12: Suspiciously clean formatted output at inhuman speed.
|
||||||
|
|
||||||
|
Long messages with perfect markdown tables, structured formatting, zero
|
||||||
|
typos, and code blocks that appear faster than any human could type.
|
||||||
|
"""
|
||||||
|
content = message.content or ""
|
||||||
|
if len(content) < CLEAN_FORMAT_MIN_LEN:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Check speed — how fast since last message?
|
||||||
|
now = time.time()
|
||||||
|
if profile.last_message_ts > 0:
|
||||||
|
gap = now - profile.last_message_ts
|
||||||
|
if gap > CLEAN_FORMAT_SPEED_CEIL:
|
||||||
|
return 0.0 # Took long enough to be human
|
||||||
|
|
||||||
|
# Score structural indicators
|
||||||
|
indicators = 0
|
||||||
|
if "```" in content:
|
||||||
|
indicators += 1 # Code blocks
|
||||||
|
if "| " in content and " |" in content:
|
||||||
|
indicators += 2 # Markdown tables
|
||||||
|
if content.count("\n") > 10:
|
||||||
|
indicators += 1 # Many lines
|
||||||
|
if "**" in content and "__" in content:
|
||||||
|
indicators += 1 # Mixed formatting
|
||||||
|
|
||||||
|
# Check for zero-typo heuristic: ratio of dictionary-like words
|
||||||
|
# (simplified: just check for consistent capitalisation patterns)
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > 5:
|
||||||
|
# Structured: many lines start with same prefix pattern (bullet, number, etc.)
|
||||||
|
prefixes = [line.strip()[:2] for line in lines if line.strip()]
|
||||||
|
from collections import Counter
|
||||||
|
prefix_counts = Counter(prefixes)
|
||||||
|
if prefix_counts and prefix_counts.most_common(1)[0][1] > len(lines) * 0.5:
|
||||||
|
indicators += 2 # Very structured repeated format
|
||||||
|
|
||||||
|
if indicators >= 3:
|
||||||
|
return WEIGHT_CLEAN_FORMAT * (indicators / 3)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _check_trigger_snipe(self, message: discord.Message, profile: UserProfile) -> float:
|
||||||
|
"""
|
||||||
|
Heuristic 13: Token-snipe / trigger-word response.
|
||||||
|
|
||||||
|
Selfbots monitor for specific trigger words (giveaway, nitro, free, etc.)
|
||||||
|
and respond instantly. We track when trigger words appear, then check if
|
||||||
|
this user responded impossibly fast.
|
||||||
|
"""
|
||||||
|
content_lower = (message.content or "").lower()
|
||||||
|
channel_id = message.channel.id
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# First, check if THIS message contains trigger words (record for others)
|
||||||
|
for word in TRIGGER_WORDS:
|
||||||
|
if word in content_lower:
|
||||||
|
self._trigger_cache[channel_id] = (word, now)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Then, check if user is responding to a recent trigger
|
||||||
|
cached = self._trigger_cache.get(channel_id)
|
||||||
|
if cached:
|
||||||
|
trigger_word, trigger_ts = cached
|
||||||
|
response_ms = (now - trigger_ts) * 1000
|
||||||
|
# Skip if this IS the trigger message itself
|
||||||
|
if response_ms > 50 and response_ms < TRIGGER_RESPONSE_CEIL_MS:
|
||||||
|
profile.trigger_responses += 1
|
||||||
|
if profile.trigger_responses >= MIN_TRIGGER_HITS:
|
||||||
|
return WEIGHT_TOKEN_SNIPE * (profile.trigger_responses / MIN_TRIGGER_HITS)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
async def _compute_score(self, message: discord.Message, profile: UserProfile) -> Tuple[float, List[str]]:
|
async def _compute_score(self, message: discord.Message, profile: UserProfile) -> Tuple[float, List[str]]:
|
||||||
"""Run all heuristics and return (total_score, list_of_triggered_reasons)."""
|
"""Run all heuristics and return (total_score, list_of_triggered_reasons)."""
|
||||||
reasons = []
|
reasons = []
|
||||||
|
|
@ -395,6 +655,64 @@ class SelfbotGuard(commands.Cog):
|
||||||
reasons.append(f"Rapid-fire burst ({BURST_MESSAGES}+ msgs in {BURST_WINDOW_SECS}s) (+{s6:.0f})")
|
reasons.append(f"Rapid-fire burst ({BURST_MESSAGES}+ msgs in {BURST_WINDOW_SECS}s) (+{s6:.0f})")
|
||||||
profile.score += s6
|
profile.score += s6
|
||||||
|
|
||||||
|
# Heuristic 7: Cross-channel spam
|
||||||
|
s7 = self._check_cross_channel(message, profile)
|
||||||
|
if s7 > 0:
|
||||||
|
reasons.append(f"Cross-channel spam ({CROSS_CHAN_MIN_CHANNELS}+ channels in {CROSS_CHAN_WINDOW_SECS}s) (+{s7:.0f})")
|
||||||
|
profile.score += s7
|
||||||
|
|
||||||
|
# Heuristic 8: Cross-server simultaneous activity
|
||||||
|
s8 = self._check_cross_server(message, profile)
|
||||||
|
if s8 > 0:
|
||||||
|
reasons.append(f"Cross-server simultaneous activity (<{CROSS_SERVER_WINDOW_SECS}s between guilds) (+{s8:.0f})")
|
||||||
|
profile.score += s8
|
||||||
|
|
||||||
|
# Heuristic 9: Edit cadence (scored in on_message_edit listener)
|
||||||
|
if len(profile.edit_delays_ms) >= MIN_EDITS_FOR_CADENCE:
|
||||||
|
delays = list(profile.edit_delays_ms)
|
||||||
|
mean_d = sum(delays) / len(delays)
|
||||||
|
if mean_d > 0:
|
||||||
|
variance = sum((d - mean_d) ** 2 for d in delays) / len(delays)
|
||||||
|
if variance < EDIT_VARIANCE_CEIL_MS:
|
||||||
|
s9 = WEIGHT_EDIT_CADENCE * (1.0 + (1.0 - variance / EDIT_VARIANCE_CEIL_MS))
|
||||||
|
reasons.append(f"Precise edit timing (variance {variance:.0f}ms) (+{s9:.0f})")
|
||||||
|
profile.score += s9
|
||||||
|
|
||||||
|
# Heuristic 10: Presence anomaly
|
||||||
|
s10 = self._check_presence_anomaly(message, profile)
|
||||||
|
if s10 > 0:
|
||||||
|
reasons.append(f"Sending messages while offline/invisible ({profile.presence_anomalies}x) (+{s10:.0f})")
|
||||||
|
profile.score += s10
|
||||||
|
|
||||||
|
# Heuristic 11: Fast reactions (scored in on_reaction_add listener)
|
||||||
|
if profile.fast_reactions >= MIN_FAST_REACTIONS:
|
||||||
|
s11 = WEIGHT_REACTION_SNIPE * (profile.fast_reactions / MIN_FAST_REACTIONS)
|
||||||
|
reasons.append(f"Instant reactions (<{REACTION_SPEED_CEIL_MS}ms, {profile.fast_reactions}x) (+{s11:.0f})")
|
||||||
|
profile.score += s11
|
||||||
|
|
||||||
|
# Heuristic 12: Clean formatting at speed
|
||||||
|
s12 = self._check_clean_formatting(message, profile)
|
||||||
|
if s12 > 0:
|
||||||
|
reasons.append(f"Suspiciously clean structured output at speed (+{s12:.0f})")
|
||||||
|
profile.score += s12
|
||||||
|
|
||||||
|
# Heuristic 13: Trigger-word sniping
|
||||||
|
s13 = self._check_trigger_snipe(message, profile)
|
||||||
|
if s13 > 0:
|
||||||
|
reasons.append(f"Instant response to trigger words ({profile.trigger_responses}x) (+{s13:.0f})")
|
||||||
|
profile.score += s13
|
||||||
|
|
||||||
|
# Heuristic 14: Self-delete patterns (scored in on_message_delete listener)
|
||||||
|
if len(profile.self_delete_intervals) >= MIN_SELF_DELETES:
|
||||||
|
intervals = list(profile.self_delete_intervals)
|
||||||
|
mean_i = sum(intervals) / len(intervals)
|
||||||
|
if mean_i > 0:
|
||||||
|
variance_i = sum((i - mean_i) ** 2 for i in intervals) / len(intervals)
|
||||||
|
if variance_i < SELF_DELETE_VARIANCE_MS:
|
||||||
|
s14 = WEIGHT_SELF_DELETE * (1.0 + (1.0 - variance_i / SELF_DELETE_VARIANCE_MS))
|
||||||
|
reasons.append(f"Timed self-deletion pattern (variance {variance_i:.0f}ms) (+{s14:.0f})")
|
||||||
|
profile.score += s14
|
||||||
|
|
||||||
profile.last_scored = time.time()
|
profile.last_scored = time.time()
|
||||||
return profile.score, reasons
|
return profile.score, reasons
|
||||||
|
|
||||||
|
|
@ -601,11 +919,21 @@ class SelfbotGuard(commands.Cog):
|
||||||
profile.timestamps.append(now)
|
profile.timestamps.append(now)
|
||||||
profile.hour_buckets.add(datetime.now(timezone.utc).hour)
|
profile.hour_buckets.add(datetime.now(timezone.utc).hour)
|
||||||
profile.last_active = now
|
profile.last_active = now
|
||||||
profile.last_message_ts = now
|
|
||||||
|
|
||||||
# Run heuristics
|
# Track message send time for self-delete correlation (heuristic 14)
|
||||||
|
self._message_send_times[message.id] = (member.id, guild.id, now)
|
||||||
|
# Prune old entries (keep last 1000)
|
||||||
|
if len(self._message_send_times) > 1000:
|
||||||
|
oldest_keys = sorted(self._message_send_times, key=lambda k: self._message_send_times[k][2])[:500]
|
||||||
|
for k in oldest_keys:
|
||||||
|
self._message_send_times.pop(k, None)
|
||||||
|
|
||||||
|
# Run heuristics (note: last_message_ts is used by _check_clean_formatting)
|
||||||
score, reasons = await self._compute_score(message, profile)
|
score, reasons = await self._compute_score(message, profile)
|
||||||
|
|
||||||
|
# Update last_message_ts AFTER scoring (heuristic 12 needs the gap)
|
||||||
|
profile.last_message_ts = now
|
||||||
|
|
||||||
# Check threshold
|
# Check threshold
|
||||||
threshold = conf["score_threshold"]
|
threshold = conf["score_threshold"]
|
||||||
if score >= threshold and not profile.flagged:
|
if score >= threshold and not profile.flagged:
|
||||||
|
|
@ -626,6 +954,77 @@ class SelfbotGuard(commands.Cog):
|
||||||
member.id, guild.id, score, action_result,
|
member.id, guild.id, score, action_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_message_edit(self, before: discord.Message, after: discord.Message):
|
||||||
|
"""Heuristic 9: Track edit timing cadence."""
|
||||||
|
if not after.guild or after.author.bot:
|
||||||
|
return
|
||||||
|
conf = await self.config.guild(after.guild).all()
|
||||||
|
if not conf["enabled"]:
|
||||||
|
return
|
||||||
|
member = after.author
|
||||||
|
if not isinstance(member, discord.Member):
|
||||||
|
return
|
||||||
|
if await self._is_exempt(member):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate edit delay
|
||||||
|
created_ts = before.created_at.timestamp()
|
||||||
|
edited_ts = after.edited_at.timestamp() if after.edited_at else time.time()
|
||||||
|
delay_ms = (edited_ts - created_ts) * 1000
|
||||||
|
|
||||||
|
profile = self._get_profile(after.guild.id, member.id)
|
||||||
|
profile.edit_delays_ms.append(delay_ms)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_reaction_add(self, reaction: discord.Reaction, user):
|
||||||
|
"""Heuristic 11: Track reaction speed."""
|
||||||
|
if not reaction.message.guild or user.bot:
|
||||||
|
return
|
||||||
|
if not isinstance(user, discord.Member):
|
||||||
|
return
|
||||||
|
conf = await self.config.guild(reaction.message.guild).all()
|
||||||
|
if not conf["enabled"]:
|
||||||
|
return
|
||||||
|
if await self._is_exempt(user):
|
||||||
|
return
|
||||||
|
|
||||||
|
# How fast was the reaction added after the message was sent?
|
||||||
|
msg_ts = reaction.message.created_at.timestamp()
|
||||||
|
now = time.time()
|
||||||
|
delay_ms = (now - msg_ts) * 1000
|
||||||
|
|
||||||
|
if delay_ms < REACTION_SPEED_CEIL_MS:
|
||||||
|
profile = self._get_profile(reaction.message.guild.id, user.id)
|
||||||
|
profile.fast_reactions += 1
|
||||||
|
log.debug(
|
||||||
|
"Fast reaction from user %s in guild %s: %.0fms (count: %d)",
|
||||||
|
user.id, reaction.message.guild.id, delay_ms, profile.fast_reactions,
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_message_delete(self, message: discord.Message):
|
||||||
|
"""Heuristic 14: Track self-deletion patterns."""
|
||||||
|
if not message.guild or message.author.bot:
|
||||||
|
return
|
||||||
|
if not isinstance(message.author, discord.Member):
|
||||||
|
return
|
||||||
|
|
||||||
|
conf = await self.config.guild(message.guild).all()
|
||||||
|
if not conf["enabled"]:
|
||||||
|
return
|
||||||
|
if await self._is_exempt(message.author):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if this was tracked at send time
|
||||||
|
send_info = self._message_send_times.pop(message.id, None)
|
||||||
|
if send_info:
|
||||||
|
author_id, guild_id, send_ts = send_info
|
||||||
|
if author_id == message.author.id:
|
||||||
|
delete_delay_ms = (time.time() - send_ts) * 1000
|
||||||
|
profile = self._get_profile(guild_id, author_id)
|
||||||
|
profile.self_delete_intervals.append(delete_delay_ms)
|
||||||
|
|
||||||
# ═════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════
|
||||||
# Commands — Admin configuration
|
# Commands — Admin configuration
|
||||||
# ═════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════
|
||||||
|
|
@ -952,6 +1351,13 @@ class SelfbotGuard(commands.Cog):
|
||||||
embed.add_field(name="Fast Responses", value=str(len(profile.response_times_ms)), inline=True)
|
embed.add_field(name="Fast Responses", value=str(len(profile.response_times_ms)), inline=True)
|
||||||
embed.add_field(name="Burst Events", value=str(profile.bursts), inline=True)
|
embed.add_field(name="Burst Events", value=str(profile.bursts), inline=True)
|
||||||
embed.add_field(name="Unique Patterns", value=str(len(profile.patterns)), inline=True)
|
embed.add_field(name="Unique Patterns", value=str(len(profile.patterns)), inline=True)
|
||||||
|
embed.add_field(name="Cross-Channel Hits", value=str(profile.cross_channel_hits), inline=True)
|
||||||
|
embed.add_field(name="Cross-Server Hits", value=str(profile.cross_server_hits), inline=True)
|
||||||
|
embed.add_field(name="Edit Cadence Samples", value=str(len(profile.edit_delays_ms)), inline=True)
|
||||||
|
embed.add_field(name="Presence Anomalies", value=str(profile.presence_anomalies), inline=True)
|
||||||
|
embed.add_field(name="Fast Reactions", value=str(profile.fast_reactions), inline=True)
|
||||||
|
embed.add_field(name="Trigger Responses", value=str(profile.trigger_responses), inline=True)
|
||||||
|
embed.add_field(name="Self-Deletes Tracked", value=str(len(profile.self_delete_intervals)), inline=True)
|
||||||
|
|
||||||
threshold = await self.config.guild(ctx.guild).score_threshold()
|
threshold = await self.config.guild(ctx.guild).score_threshold()
|
||||||
bar_len = 20
|
bar_len = 20
|
||||||
|
|
@ -983,6 +1389,8 @@ class SelfbotGuard(commands.Cog):
|
||||||
del self._profiles[ctx.guild.id]
|
del self._profiles[ctx.guild.id]
|
||||||
if ctx.guild.id in self._recent_messages:
|
if ctx.guild.id in self._recent_messages:
|
||||||
del self._recent_messages[ctx.guild.id]
|
del self._recent_messages[ctx.guild.id]
|
||||||
|
if ctx.guild.id in self._cross_channel:
|
||||||
|
del self._cross_channel[ctx.guild.id]
|
||||||
await ctx.send("✅ All SelfbotGuard data for this server has been reset.")
|
await ctx.send("✅ All SelfbotGuard data for this server has been reset.")
|
||||||
|
|
||||||
# ── Hybrid slash support ─────────────────────────────────
|
# ── Hybrid slash support ─────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue