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:
JARVIS 2026-05-08 13:14:10 +00:00
parent ebafe6567d
commit dfff1c5b3e
2 changed files with 413 additions and 5 deletions

View file

@ -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",

View file

@ -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 ─────────────────────────────────