EveCogs/selfbotguard/selfbotguard.py
JARVIS ebafe6567d feat: add SelfbotGuard v1.0.0 — advanced selfbot detection & punishment
Detection heuristics:
  - Rich-embed abuse (user accounts can't send rich embeds)
  - Inhuman response speed (<300ms consistently)
  - Message timing precision analysis
  - 24/7 activity profiling
  - Automated command-response pattern matching
  - Rapid-fire burst detection

Configurable per-server:
  - Toggle on/off (default: off)
  - Actions: log, warn, mute, kick, ban
  - Sensitivity: low/medium/high + custom threshold
  - Log channel, exempt roles/users
  - Staff notifications, mute duration

Commands: [p]sbguard enable/disable/action/sensitivity/threshold/logchannel/scan/flagged/reset
Slash: /sbguard-status, /sbguard-scan
2026-05-08 12:54:16 +00:00

1005 lines
44 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
SelfbotGuard v1.0.0 — Advanced Selfbot Detection & Punishment
================================================================
Cog for Red-DiscordBot.
Detection heuristics:
1. Rich-embed abuse — User accounts cannot send rich embeds via the client.
2. Response-time analysis — Inhuman reply speeds (< 300ms consistently).
3. Timing precision — Messages at exact intervals (variance < 50ms).
4. Activity profiling — 24/7 activity across all hours without gaps.
5. Pattern matching — Automated prefix→response command patterns.
6. Burst detection — Rapid-fire messages exceeding human typing speed.
Each heuristic contributes to a cumulative suspicion score. When the score
exceeds the server's threshold, the configured action is taken.
"""
from __future__ import annotations
import asyncio
import logging
import math
import time
from collections import defaultdict, deque
from datetime import datetime, timezone
from typing import Dict, Deque, List, Optional, Set, Tuple
import discord
from discord import app_commands
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
log = logging.getLogger("red.selfbotguard")
# ═══════════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════════
# Heuristic weights (how much each detection adds to suspicion score)
WEIGHT_RICH_EMBED = 40 # Very strong signal
WEIGHT_RESPONSE_SPEED = 15 # Per fast-response instance
WEIGHT_TIMING_PRECISION = 20 # Consistent intervals
WEIGHT_ACTIVITY_247 = 25 # Active across all hour buckets
WEIGHT_PATTERN_MATCH = 15 # Command-response patterns
WEIGHT_BURST = 10 # Rapid-fire messages
# Thresholds for heuristics
RESPONSE_TIME_FLOOR_MS = 300 # Faster than this = suspicious (ms)
TIMING_VARIANCE_CEIL_MS = 50 # Lower variance = more suspicious (ms)
BURST_MESSAGES = 8 # Messages in burst window
BURST_WINDOW_SECS = 3 # Window for burst detection (seconds)
ACTIVITY_HOUR_COVERAGE = 20 # Out of 24 hours — if active in this many, suspicious
MIN_MESSAGES_FOR_TIMING = 10 # Need this many messages before timing analysis
MIN_RESPONSES_FOR_SPEED = 5 # Need this many fast responses before flagging
PATTERN_REPEAT_THRESHOLD= 5 # Same prefix→response pattern this many times
# Data retention
MAX_TIMESTAMPS_PER_USER = 200 # Rolling window of message timestamps
MAX_RESPONSE_PAIRS = 100 # Rolling window of response-time pairs
SCORE_DECAY_HOURS = 24 # Score decays fully over this period
PRUNE_INACTIVE_DAYS = 7 # Prune user data after this many days inactive
# Sensitivity presets
SENSITIVITY_PRESETS = {
"low": {"score_threshold": 150, "description": "Fewer false positives, only catches obvious selfbots"},
"medium": {"score_threshold": 100, "description": "Balanced detection — recommended for most servers"},
"high": {"score_threshold": 60, "description": "Aggressive detection — may flag power users, review logs"},
}
# Action types
VALID_ACTIONS = ("log", "warn", "mute", "kick", "ban")
# Colours
COLOUR_ALERT = discord.Colour.red()
COLOUR_WARN = discord.Colour.orange()
COLOUR_INFO = discord.Colour.blurple()
COLOUR_OK = discord.Colour.green()
# ═══════════════════════════════════════════════════════════════
# User tracking dataclass
# ═══════════════════════════════════════════════════════════════
class UserProfile:
"""Tracks behavioural data for a single user in a guild."""
__slots__ = (
"user_id", "guild_id", "timestamps", "response_times_ms",
"hour_buckets", "patterns", "bursts", "embed_strikes",
"score", "last_scored", "last_message_ts", "flagged",
"action_taken", "last_active",
)
def __init__(self, user_id: int, guild_id: int):
self.user_id = user_id
self.guild_id = guild_id
self.timestamps: Deque[float] = deque(maxlen=MAX_TIMESTAMPS_PER_USER)
self.response_times_ms: Deque[float] = deque(maxlen=MAX_RESPONSE_PAIRS)
self.hour_buckets: Set[int] = set() # Hours (0-23) the user has been active
self.patterns: Dict[str, int] = defaultdict(int) # "prefix→response" counters
self.bursts: int = 0 # Number of burst events detected
self.embed_strikes: int = 0 # Rich embeds sent by non-bot user
self.score: float = 0.0
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.flagged: bool = False
self.action_taken: Optional[str] = None
self.last_active: float = time.time()
def decay_score(self) -> float:
"""Apply time-based decay to the score."""
if self.last_scored <= 0:
return self.score
elapsed_h = (time.time() - self.last_scored) / 3600
if elapsed_h >= SCORE_DECAY_HOURS:
self.score = 0.0
else:
decay = 1.0 - (elapsed_h / SCORE_DECAY_HOURS)
self.score *= decay
self.last_scored = time.time()
return self.score
# ═══════════════════════════════════════════════════════════════
# The Cog
# ═══════════════════════════════════════════════════════════════
class SelfbotGuard(commands.Cog):
"""Advanced selfbot detection with per-server configurable punishments."""
__version__ = "1.0.0"
__author__ = ["everestmcarthur"]
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=58_310_627_41, force_registration=True)
# Per-guild defaults
self.config.register_guild(
enabled=False,
action="log", # log | warn | mute | kick | ban
sensitivity="medium", # low | medium | high
score_threshold=100, # Override, or auto from sensitivity
log_channel=None, # Channel ID for alerts
exempt_roles=[], # Role IDs that bypass detection
exempt_users=[], # User IDs that bypass detection
mute_duration=600, # Seconds for mute/timeout (default 10min)
flagged_users={}, # user_id -> {score, reason, timestamp}
notify_staff=True, # DM the guild owner on detection
auto_prune=True, # Auto-prune old tracking data
)
# In-memory tracking: guild_id -> user_id -> UserProfile
self._profiles: Dict[int, Dict[int, UserProfile]] = defaultdict(dict)
# Recent messages cache for response-time analysis
# guild_id -> channel_id -> (author_id, timestamp, content_prefix)
self._recent_messages: Dict[int, Dict[int, Tuple[int, float, str]]] = defaultdict(dict)
# Background task
self._prune_task: Optional[asyncio.Task] = None
async def cog_load(self):
"""Start background tasks."""
self._prune_task = self.bot.loop.create_task(self._prune_loop())
log.info("SelfbotGuard v%s loaded.", self.__version__)
async def cog_unload(self):
"""Clean up."""
if self._prune_task and not self._prune_task.done():
self._prune_task.cancel()
log.info("SelfbotGuard unloaded.")
# ── Helpers ──────────────────────────────────────────────
def _get_profile(self, guild_id: int, user_id: int) -> UserProfile:
"""Get or create a UserProfile."""
guild_profiles = self._profiles[guild_id]
if user_id not in guild_profiles:
guild_profiles[user_id] = UserProfile(user_id, guild_id)
return guild_profiles[user_id]
async def _is_exempt(self, member: discord.Member) -> bool:
"""Check if a member is exempt from detection."""
if member.bot:
return True # Actual bots are not selfbots
if member.guild_permissions.administrator:
return True
if member.id == member.guild.owner_id:
return True
guild_conf = await self.config.guild(member.guild).all()
if member.id in guild_conf["exempt_users"]:
return True
member_role_ids = {r.id for r in member.roles}
if member_role_ids & set(guild_conf["exempt_roles"]):
return True
return False
async def _get_log_channel(self, guild: discord.Guild) -> Optional[discord.TextChannel]:
"""Get the configured log channel."""
ch_id = await self.config.guild(guild).log_channel()
if ch_id:
return guild.get_channel(ch_id)
return None
# ── Detection heuristics ─────────────────────────────────
def _check_rich_embeds(self, message: discord.Message, profile: UserProfile) -> float:
"""
Heuristic 1: Rich embeds from non-bot user accounts.
The Discord client does NOT allow regular users to send rich embeds.
URL unfurls (type='rich' with url field matching message content) are excluded.
If a user account sends a genuine rich embed, it's almost certainly a selfbot.
"""
if not message.embeds:
return 0.0
score = 0.0
for embed in message.embeds:
# Skip URL unfurls — these are normal
if embed.type in ("image", "video", "gifv", "link", "article"):
continue
# If it's a "rich" embed, check if it's just a URL preview
if embed.type == "rich":
# URL unfurls typically have a url that matches something in message content
if embed.url and embed.url in (message.content or ""):
continue
# If embed has no title/description/fields, it's likely a provider embed
if not embed.title and not embed.description and not embed.fields:
continue
# This is a real rich embed from a user account — strong selfbot signal
profile.embed_strikes += 1
score += WEIGHT_RICH_EMBED
log.debug(
"Rich embed detected from user %s in guild %s (strike %d)",
message.author.id, message.guild.id, profile.embed_strikes
)
return score
def _check_response_speed(self, message: discord.Message, profile: UserProfile) -> float:
"""
Heuristic 2: Inhuman response speed.
If a user consistently responds to other users' messages in < 300ms,
they're likely automated. We track response times per channel.
"""
guild_id = message.guild.id
channel_id = message.channel.id
now = time.time()
# Check if there's a recent message in this channel from someone else
last = self._recent_messages.get(guild_id, {}).get(channel_id)
if last and last[0] != message.author.id:
response_ms = (now - last[1]) * 1000
if response_ms < RESPONSE_TIME_FLOOR_MS:
profile.response_times_ms.append(response_ms)
# Update the recent message cache
if guild_id not in self._recent_messages:
self._recent_messages[guild_id] = {}
content_prefix = (message.content or "")[:50]
self._recent_messages[guild_id][channel_id] = (message.author.id, now, content_prefix)
# Score based on accumulated fast responses
fast_count = sum(1 for t in profile.response_times_ms if t < RESPONSE_TIME_FLOOR_MS)
if fast_count >= MIN_RESPONSES_FOR_SPEED:
return WEIGHT_RESPONSE_SPEED * (fast_count / MIN_RESPONSES_FOR_SPEED)
return 0.0
def _check_timing_precision(self, profile: UserProfile) -> float:
"""
Heuristic 3: Message timing precision.
Selfbots often send messages at very precise intervals (e.g., exactly
every 5.000 seconds). We measure the variance of inter-message delays.
Low variance = likely automated.
"""
if len(profile.timestamps) < MIN_MESSAGES_FOR_TIMING:
return 0.0
timestamps = sorted(profile.timestamps)
deltas = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps) - 1)]
if not deltas:
return 0.0
# Filter out very large gaps (> 60s) — those are natural pauses
active_deltas = [d for d in deltas if d < 60.0]
if len(active_deltas) < 5:
return 0.0
mean = sum(active_deltas) / len(active_deltas)
if mean <= 0:
return 0.0
variance_ms = (sum((d - mean) ** 2 for d in active_deltas) / len(active_deltas)) * 1000
if variance_ms < TIMING_VARIANCE_CEIL_MS:
# Very precise timing — scale score by how precise
precision_factor = 1.0 - (variance_ms / TIMING_VARIANCE_CEIL_MS)
return WEIGHT_TIMING_PRECISION * (1.0 + precision_factor)
return 0.0
def _check_activity_247(self, profile: UserProfile) -> float:
"""
Heuristic 4: 24/7 activity profiling.
Normal humans sleep. If a user has sent messages across 20+ distinct
hours of the day, they're likely automated.
"""
if len(profile.hour_buckets) >= ACTIVITY_HOUR_COVERAGE:
coverage = len(profile.hour_buckets) / 24.0
return WEIGHT_ACTIVITY_247 * coverage
return 0.0
def _check_patterns(self, message: discord.Message, profile: UserProfile) -> float:
"""
Heuristic 5: Automated command-response patterns.
Selfbots often respond with the same pattern to the same triggers.
We track content prefixes and look for repetitive automated responses.
"""
content = (message.content or "").strip()
if not content or len(content) < 2:
return 0.0
# Build a simplified pattern key (first 30 chars, lowered)
pattern_key = content[:30].lower()
profile.patterns[pattern_key] += 1
# Check for repeated patterns
max_repeats = max(profile.patterns.values()) if profile.patterns else 0
if max_repeats >= PATTERN_REPEAT_THRESHOLD:
return WEIGHT_PATTERN_MATCH * (max_repeats / PATTERN_REPEAT_THRESHOLD)
return 0.0
def _check_burst(self, profile: UserProfile) -> float:
"""
Heuristic 6: Rapid-fire burst detection.
If a user sends BURST_MESSAGES+ messages within BURST_WINDOW_SECS,
that exceeds normal human typing speed.
"""
if len(profile.timestamps) < BURST_MESSAGES:
return 0.0
recent = sorted(profile.timestamps)[-BURST_MESSAGES:]
window = recent[-1] - recent[0]
if window <= BURST_WINDOW_SECS:
profile.bursts += 1
return WEIGHT_BURST * profile.bursts
return 0.0
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)."""
reasons = []
# Apply decay first
profile.decay_score()
# Heuristic 1: Rich embeds
s1 = self._check_rich_embeds(message, profile)
if s1 > 0:
reasons.append(f"Rich embed from user account (+{s1:.0f})")
profile.score += s1
# Heuristic 2: Response speed
s2 = self._check_response_speed(message, profile)
if s2 > 0:
reasons.append(f"Inhuman response speed (<{RESPONSE_TIME_FLOOR_MS}ms) (+{s2:.0f})")
profile.score += s2
# Heuristic 3: Timing precision
s3 = self._check_timing_precision(profile)
if s3 > 0:
reasons.append(f"Precise message timing (low variance) (+{s3:.0f})")
profile.score += s3
# Heuristic 4: 24/7 activity
s4 = self._check_activity_247(profile)
if s4 > 0:
reasons.append(f"24/7 activity ({len(profile.hour_buckets)}/24 hours) (+{s4:.0f})")
profile.score += s4
# Heuristic 5: Pattern matching
s5 = self._check_patterns(message, profile)
if s5 > 0:
reasons.append(f"Repetitive automated patterns (+{s5:.0f})")
profile.score += s5
# Heuristic 6: Burst detection
s6 = self._check_burst(profile)
if s6 > 0:
reasons.append(f"Rapid-fire burst ({BURST_MESSAGES}+ msgs in {BURST_WINDOW_SECS}s) (+{s6:.0f})")
profile.score += s6
profile.last_scored = time.time()
return profile.score, reasons
# ── Punishment execution ─────────────────────────────────
async def _execute_action(
self, member: discord.Member, action: str, score: float,
reasons: List[str], mute_duration: int
) -> str:
"""Execute the configured punishment. Returns a status string."""
reason_text = f"SelfbotGuard — score {score:.0f}: {'; '.join(reasons[:3])}"
try:
if action == "log":
return "Logged (no action taken)"
elif action == "warn":
try:
embed = discord.Embed(
title="⚠️ SelfbotGuard Warning",
description=(
f"Your account has been flagged for automated behaviour in "
f"**{member.guild.name}**.\n\n"
f"If you believe this is an error, please contact the server staff."
),
colour=COLOUR_WARN,
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Suspicion Score", value=f"{score:.0f}", inline=True)
await member.send(embed=embed)
except discord.Forbidden:
pass
return "Warned via DM"
elif action == "mute":
duration = min(mute_duration, 2419200) # Discord max: 28 days
await member.timeout(
discord.utils.utcnow() + __import__("datetime").timedelta(seconds=duration),
reason=reason_text,
)
return f"Timed out for {duration}s"
elif action == "kick":
try:
embed = discord.Embed(
title="🚫 SelfbotGuard — Kicked",
description=(
f"You were kicked from **{member.guild.name}** for suspected "
f"selfbot activity.\nScore: {score:.0f}"
),
colour=COLOUR_ALERT,
)
await member.send(embed=embed)
except discord.Forbidden:
pass
await member.kick(reason=reason_text)
return "Kicked"
elif action == "ban":
try:
embed = discord.Embed(
title="🔨 SelfbotGuard — Banned",
description=(
f"You were banned from **{member.guild.name}** for suspected "
f"selfbot activity.\nScore: {score:.0f}"
),
colour=COLOUR_ALERT,
)
await member.send(embed=embed)
except discord.Forbidden:
pass
await member.ban(reason=reason_text, delete_message_days=0)
return "Banned"
except discord.Forbidden:
return f"Failed (missing permissions for {action})"
except discord.HTTPException as e:
return f"Failed ({e})"
return "Unknown action"
# ── Alert / logging ──────────────────────────────────────
async def _send_alert(
self, guild: discord.Guild, member: discord.Member,
score: float, reasons: List[str], action_result: str
):
"""Send an alert to the log channel."""
log_channel = await self._get_log_channel(guild)
if not log_channel:
return
embed = discord.Embed(
title="🛡️ SelfbotGuard Detection",
colour=COLOUR_ALERT if "Ban" in action_result or "Kick" in action_result else COLOUR_WARN,
timestamp=datetime.now(timezone.utc),
)
embed.set_thumbnail(url=member.display_avatar.url if member.display_avatar else None)
embed.add_field(name="User", value=f"{member.mention} (`{member.id}`)", inline=True)
embed.add_field(name="Score", value=f"**{score:.0f}**", inline=True)
embed.add_field(name="Action", value=action_result, inline=True)
embed.add_field(
name="Triggered Heuristics",
value="\n".join(f"• {r}" for r in reasons) or "None",
inline=False,
)
embed.add_field(name="Account Created", value=discord.utils.format_dt(member.created_at, "R"), inline=True)
embed.add_field(name="Joined Server", value=discord.utils.format_dt(member.joined_at, "R") if member.joined_at else "Unknown", inline=True)
embed.set_footer(text=f"SelfbotGuard v{self.__version__}")
try:
await log_channel.send(embed=embed)
except discord.HTTPException:
pass
# Optionally DM guild owner
conf = await self.config.guild(guild).all()
if conf["notify_staff"] and guild.owner:
try:
owner_embed = discord.Embed(
title="🛡️ SelfbotGuard Alert",
description=(
f"A potential selfbot was detected in **{guild.name}**:\n"
f"**{member}** (`{member.id}`) — Score: {score:.0f}\n"
f"Action taken: {action_result}"
),
colour=COLOUR_WARN,
)
await guild.owner.send(embed=owner_embed)
except discord.Forbidden:
pass
# ── Persistence ──────────────────────────────────────────
async def _save_flagged_user(self, guild: discord.Guild, profile: UserProfile, reasons: List[str]):
"""Persist flagged user to config."""
async with self.config.guild(guild).flagged_users() as flagged:
flagged[str(profile.user_id)] = {
"score": round(profile.score, 1),
"reasons": reasons[:5],
"timestamp": datetime.now(timezone.utc).isoformat(),
"action_taken": profile.action_taken,
"embed_strikes": profile.embed_strikes,
"bursts": profile.bursts,
}
# ── Background prune task ────────────────────────────────
async def _prune_loop(self):
"""Periodically prune stale user tracking data."""
await self.bot.wait_until_ready()
while True:
try:
await asyncio.sleep(3600) # Every hour
cutoff = time.time() - (PRUNE_INACTIVE_DAYS * 86400)
pruned = 0
for guild_id in list(self._profiles.keys()):
guild_profiles = self._profiles[guild_id]
stale = [
uid for uid, p in guild_profiles.items()
if p.last_active < cutoff and not p.flagged
]
for uid in stale:
del guild_profiles[uid]
pruned += 1
if not guild_profiles:
del self._profiles[guild_id]
if pruned:
log.debug("SelfbotGuard pruned %d stale user profiles.", pruned)
except asyncio.CancelledError:
break
except Exception:
log.exception("Error in SelfbotGuard prune loop")
await asyncio.sleep(60)
# ═════════════════════════════════════════════════════════
# Event listener — the core detection loop
# ═════════════════════════════════════════════════════════
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Analyse every message for selfbot indicators."""
# Skip DMs, bot messages, messages without a guild
if not message.guild or message.author.bot:
return
# Check if enabled
guild = message.guild
conf = await self.config.guild(guild).all()
if not conf["enabled"]:
return
member = message.author
if not isinstance(member, discord.Member):
return
# Check exemptions
if await self._is_exempt(member):
return
# Get / update profile
profile = self._get_profile(guild.id, member.id)
now = time.time()
profile.timestamps.append(now)
profile.hour_buckets.add(datetime.now(timezone.utc).hour)
profile.last_active = now
profile.last_message_ts = now
# Run heuristics
score, reasons = await self._compute_score(message, profile)
# Check threshold
threshold = conf["score_threshold"]
if score >= threshold and not profile.flagged:
profile.flagged = True
action = conf["action"]
mute_duration = conf["mute_duration"]
# Execute action
action_result = await self._execute_action(member, action, score, reasons, mute_duration)
profile.action_taken = action_result
# Log and alert
await self._send_alert(guild, member, score, reasons, action_result)
await self._save_flagged_user(guild, profile, reasons)
log.info(
"SelfbotGuard flagged user %s in guild %s (score: %.0f, action: %s)",
member.id, guild.id, score, action_result,
)
# ═════════════════════════════════════════════════════════
# Commands — Admin configuration
# ═════════════════════════════════════════════════════════
@commands.group(name="sbguard", aliases=["selfbotguard", "sbg"])
@commands.guild_only()
@commands.admin_or_permissions(administrator=True)
async def sbguard(self, ctx: commands.Context):
"""🛡️ SelfbotGuard — Advanced selfbot detection & punishment.
Detects selfbots using behavioural heuristics and takes configurable action.
Disabled by default — use `[p]sbguard enable` to activate.
"""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@sbguard.command(name="enable", aliases=["on"])
async def sbguard_enable(self, ctx: commands.Context):
"""Enable SelfbotGuard for this server."""
await self.config.guild(ctx.guild).enabled.set(True)
embed = discord.Embed(
title="🛡️ SelfbotGuard Enabled",
description=(
"Selfbot detection is now **active** in this server.\n\n"
"• Set a log channel: `[p]sbguard logchannel #channel`\n"
"• Set the action: `[p]sbguard action <log|warn|mute|kick|ban>`\n"
"• Adjust sensitivity: `[p]sbguard sensitivity <low|medium|high>`"
),
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@sbguard.command(name="disable", aliases=["off"])
async def sbguard_disable(self, ctx: commands.Context):
"""Disable SelfbotGuard for this server."""
await self.config.guild(ctx.guild).enabled.set(False)
embed = discord.Embed(
title="🛡️ SelfbotGuard Disabled",
description="Selfbot detection is now **inactive** in this server.",
colour=COLOUR_INFO,
)
await ctx.send(embed=embed)
@sbguard.command(name="action")
async def sbguard_action(self, ctx: commands.Context, action: str):
"""Set the punishment action when a selfbot is detected.
Valid actions: `log`, `warn`, `mute`, `kick`, `ban`
"""
action = action.lower()
if action not in VALID_ACTIONS:
return await ctx.send(
f"❌ Invalid action `{action}`. Choose from: {', '.join(f'`{a}`' for a in VALID_ACTIONS)}"
)
await self.config.guild(ctx.guild).action.set(action)
action_descriptions = {
"log": "📝 Detections will be logged but no action taken.",
"warn": "⚠️ Users will be warned via DM.",
"mute": "🔇 Users will be timed out.",
"kick": "👢 Users will be kicked from the server.",
"ban": "🔨 Users will be banned from the server.",
}
embed = discord.Embed(
title=f"🛡️ Action Set: {action.title()}",
description=action_descriptions[action],
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@sbguard.command(name="sensitivity")
async def sbguard_sensitivity(self, ctx: commands.Context, level: str):
"""Set detection sensitivity: `low`, `medium`, or `high`.
• **Low** — Score threshold 150. Fewer false positives, only catches obvious selfbots.
• **Medium** — Score threshold 100. Balanced — recommended for most servers.
• **High** — Score threshold 60. Aggressive — may flag power users, review logs carefully.
"""
level = level.lower()
if level not in SENSITIVITY_PRESETS:
return await ctx.send(
f"❌ Invalid level `{level}`. Choose from: {', '.join(f'`{l}`' for l in SENSITIVITY_PRESETS)}"
)
preset = SENSITIVITY_PRESETS[level]
await self.config.guild(ctx.guild).sensitivity.set(level)
await self.config.guild(ctx.guild).score_threshold.set(preset["score_threshold"])
embed = discord.Embed(
title=f"🛡️ Sensitivity: {level.title()}",
description=f"{preset['description']}\nScore threshold: **{preset['score_threshold']}**",
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@sbguard.command(name="threshold")
async def sbguard_threshold(self, ctx: commands.Context, value: int):
"""Set a custom score threshold (overrides sensitivity preset).
Lower = more aggressive, higher = more lenient.
Recommended range: 50-200.
"""
if value < 10 or value > 500:
return await ctx.send("❌ Threshold must be between 10 and 500.")
await self.config.guild(ctx.guild).score_threshold.set(value)
embed = discord.Embed(
title="🛡️ Custom Threshold Set",
description=f"Score threshold: **{value}**",
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@sbguard.command(name="logchannel", aliases=["log"])
async def sbguard_logchannel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Set or clear the log channel for SelfbotGuard alerts.
Run without a channel to clear.
"""
if channel:
await self.config.guild(ctx.guild).log_channel.set(channel.id)
embed = discord.Embed(
title="🛡️ Log Channel Set",
description=f"Alerts will be sent to {channel.mention}",
colour=COLOUR_OK,
)
else:
await self.config.guild(ctx.guild).log_channel.set(None)
embed = discord.Embed(
title="🛡️ Log Channel Cleared",
description="No log channel configured. Set one with `[p]sbguard logchannel #channel`.",
colour=COLOUR_INFO,
)
await ctx.send(embed=embed)
@sbguard.command(name="muteduration", aliases=["timeout"])
async def sbguard_muteduration(self, ctx: commands.Context, seconds: int):
"""Set the mute/timeout duration in seconds (default: 600 = 10 minutes).
Max: 2419200 (28 days — Discord limit).
"""
if seconds < 60:
return await ctx.send("❌ Minimum mute duration is 60 seconds.")
if seconds > 2419200:
return await ctx.send("❌ Maximum mute duration is 2,419,200 seconds (28 days).")
await self.config.guild(ctx.guild).mute_duration.set(seconds)
mins = seconds // 60
embed = discord.Embed(
title="🛡️ Mute Duration Set",
description=f"Timeout duration: **{seconds}s** (~{mins} minutes)",
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
@sbguard.command(name="exempt")
async def sbguard_exempt(self, ctx: commands.Context, target: discord.abc.Snowflake):
"""Add a role or user to the exempt list.
Exempt members bypass all selfbot detection.
Accepts a @role, @user, or raw ID.
"""
# Check if it's a role or user
if isinstance(target, discord.Role):
async with self.config.guild(ctx.guild).exempt_roles() as roles:
if target.id not in roles:
roles.append(target.id)
await ctx.send(f"✅ Role **{target.name}** is now exempt from SelfbotGuard.")
elif isinstance(target, (discord.Member, discord.User)):
async with self.config.guild(ctx.guild).exempt_users() as users:
if target.id not in users:
users.append(target.id)
await ctx.send(f"✅ User **{target}** is now exempt from SelfbotGuard.")
else:
await ctx.send("❌ Please provide a valid @role or @user mention.")
@sbguard.command(name="unexempt")
async def sbguard_unexempt(self, ctx: commands.Context, target: discord.abc.Snowflake):
"""Remove a role or user from the exempt list."""
if isinstance(target, discord.Role):
async with self.config.guild(ctx.guild).exempt_roles() as roles:
if target.id in roles:
roles.remove(target.id)
await ctx.send(f"✅ Role **{target.name}** is no longer exempt.")
elif isinstance(target, (discord.Member, discord.User)):
async with self.config.guild(ctx.guild).exempt_users() as users:
if target.id in users:
users.remove(target.id)
await ctx.send(f"✅ User **{target}** is no longer exempt.")
else:
await ctx.send("❌ Please provide a valid @role or @user mention.")
@sbguard.command(name="notifystaff", aliases=["notify"])
async def sbguard_notifystaff(self, ctx: commands.Context, toggle: bool = None):
"""Toggle DM notifications to the guild owner on detection.
Run without argument to toggle, or specify `true`/`false`.
"""
current = await self.config.guild(ctx.guild).notify_staff()
new_val = not current if toggle is None else toggle
await self.config.guild(ctx.guild).notify_staff.set(new_val)
state = "enabled" if new_val else "disabled"
embed = discord.Embed(
title=f"🛡️ Staff Notifications {state.title()}",
description=f"DM notifications to the server owner are now **{state}**.",
colour=COLOUR_OK,
)
await ctx.send(embed=embed)
# ── Status / review commands ─────────────────────────────
@sbguard.command(name="settings", aliases=["config", "status"])
async def sbguard_settings(self, ctx: commands.Context):
"""View the current SelfbotGuard configuration for this server."""
conf = await self.config.guild(ctx.guild).all()
log_ch = ctx.guild.get_channel(conf["log_channel"]) if conf["log_channel"] else None
exempt_roles = [ctx.guild.get_role(r) for r in conf["exempt_roles"] if ctx.guild.get_role(r)]
exempt_users_str = ", ".join(f"<@{u}>" for u in conf["exempt_users"][:10]) or "None"
embed = discord.Embed(
title="🛡️ SelfbotGuard Settings",
colour=COLOUR_INFO,
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Status", value="✅ Enabled" if conf["enabled"] else "❌ Disabled", inline=True)
embed.add_field(name="Action", value=conf["action"].title(), inline=True)
embed.add_field(name="Sensitivity", value=conf["sensitivity"].title(), inline=True)
embed.add_field(name="Score Threshold", value=str(conf["score_threshold"]), inline=True)
embed.add_field(name="Mute Duration", value=f"{conf['mute_duration']}s", inline=True)
embed.add_field(name="Notify Staff", value="✅" if conf["notify_staff"] else "❌", inline=True)
embed.add_field(name="Log Channel", value=log_ch.mention if log_ch else "Not set", inline=True)
embed.add_field(
name="Exempt Roles",
value=", ".join(r.mention for r in exempt_roles) or "None",
inline=False,
)
embed.add_field(name="Exempt Users", value=exempt_users_str, inline=False)
flagged_count = len(conf.get("flagged_users", {}))
embed.add_field(name="Flagged Users", value=str(flagged_count), inline=True)
# In-memory tracking stats
guild_profiles = self._profiles.get(ctx.guild.id, {})
embed.add_field(name="Tracking", value=f"{len(guild_profiles)} users", inline=True)
embed.set_footer(text=f"SelfbotGuard v{self.__version__}")
await ctx.send(embed=embed)
@sbguard.command(name="flagged", aliases=["list", "suspects"])
async def sbguard_flagged(self, ctx: commands.Context):
"""View all flagged users in this server."""
flagged = await self.config.guild(ctx.guild).flagged_users()
if not flagged:
return await ctx.send("✅ No flagged users — SelfbotGuard hasn't detected any selfbots.")
embed = discord.Embed(
title="🛡️ Flagged Users",
colour=COLOUR_WARN,
timestamp=datetime.now(timezone.utc),
)
for uid, data in list(flagged.items())[:25]:
reasons_str = "\n".join(f" • {r}" for r in data.get("reasons", [])[:3])
embed.add_field(
name=f"<@{uid}> (`{uid}`)",
value=(
f"**Score:** {data.get('score', 0)}\n"
f"**Action:** {data.get('action_taken', 'N/A')}\n"
f"**When:** {data.get('timestamp', 'N/A')[:19]}\n"
f"{reasons_str}"
),
inline=False,
)
if len(flagged) > 25:
embed.set_footer(text=f"Showing 25 of {len(flagged)} flagged users")
else:
embed.set_footer(text=f"SelfbotGuard v{self.__version__}")
await ctx.send(embed=embed)
@sbguard.command(name="unflag", aliases=["clear"])
async def sbguard_unflag(self, ctx: commands.Context, user: discord.User):
"""Remove a user from the flagged list and reset their tracking data."""
async with self.config.guild(ctx.guild).flagged_users() as flagged:
if str(user.id) in flagged:
del flagged[str(user.id)]
else:
return await ctx.send(f"❌ {user} is not in the flagged list.")
# Reset in-memory profile
guild_profiles = self._profiles.get(ctx.guild.id, {})
if user.id in guild_profiles:
del guild_profiles[user.id]
await ctx.send(f"✅ **{user}** has been unflagged and their tracking data reset.")
@sbguard.command(name="scan")
async def sbguard_scan(self, ctx: commands.Context, user: discord.Member):
"""Manually check a user's current suspicion profile.
Shows the live heuristic breakdown without triggering any action.
"""
if user.bot:
return await ctx.send("ℹ️ That's an actual bot account — SelfbotGuard only tracks user accounts.")
profile = self._profiles.get(ctx.guild.id, {}).get(user.id)
if not profile:
return await ctx.send(f"ℹ️ No tracking data for **{user}** yet.")
profile.decay_score()
embed = discord.Embed(
title=f"🔍 SelfbotGuard Scan — {user}",
colour=COLOUR_INFO,
timestamp=datetime.now(timezone.utc),
)
embed.set_thumbnail(url=user.display_avatar.url if user.display_avatar else None)
embed.add_field(name="Current Score", value=f"**{profile.score:.1f}**", inline=True)
embed.add_field(name="Flagged", value="⚠️ Yes" if profile.flagged else "✅ No", inline=True)
embed.add_field(name="Action Taken", value=profile.action_taken or "None", inline=True)
embed.add_field(name="Messages Tracked", value=str(len(profile.timestamps)), inline=True)
embed.add_field(name="Hour Coverage", value=f"{len(profile.hour_buckets)}/24", inline=True)
embed.add_field(name="Embed Strikes", value=str(profile.embed_strikes), 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="Unique Patterns", value=str(len(profile.patterns)), inline=True)
threshold = await self.config.guild(ctx.guild).score_threshold()
bar_len = 20
fill = min(int((profile.score / threshold) * bar_len), bar_len)
bar = "█" * fill + "░" * (bar_len - fill)
embed.add_field(
name="Threshold Progress",
value=f"`[{bar}]` {profile.score:.0f}/{threshold}",
inline=False,
)
embed.set_footer(text=f"SelfbotGuard v{self.__version__}")
await ctx.send(embed=embed)
@sbguard.command(name="reset")
@commands.is_owner()
async def sbguard_reset(self, ctx: commands.Context, confirm: bool = False):
"""⚠️ Reset ALL SelfbotGuard data for this server (owner only).
Pass `True` to confirm: `[p]sbguard reset True`
"""
if not confirm:
return await ctx.send(
"⚠️ This will clear ALL flagged users and tracking data for this server.\n"
"Run `[p]sbguard reset True` to confirm."
)
await self.config.guild(ctx.guild).flagged_users.set({})
if ctx.guild.id in self._profiles:
del self._profiles[ctx.guild.id]
if ctx.guild.id in self._recent_messages:
del self._recent_messages[ctx.guild.id]
await ctx.send("✅ All SelfbotGuard data for this server has been reset.")
# ── Hybrid slash support ─────────────────────────────────
@app_commands.command(name="sbguard-status", description="Check SelfbotGuard status")
@app_commands.guild_only()
@app_commands.default_permissions(administrator=True)
async def slash_sbguard_status(self, interaction: discord.Interaction):
"""Slash command: view SelfbotGuard status."""
ctx = await commands.Context.from_interaction(interaction)
await self.sbguard_settings(ctx)
@app_commands.command(name="sbguard-scan", description="Scan a user for selfbot indicators")
@app_commands.guild_only()
@app_commands.default_permissions(administrator=True)
@app_commands.describe(user="The user to scan")
async def slash_sbguard_scan(self, interaction: discord.Interaction, user: discord.Member):
"""Slash command: scan a specific user."""
ctx = await commands.Context.from_interaction(interaction)
await self.sbguard_scan(ctx, user)