added community voting and fixed bugs

This commit is contained in:
Subinacls
2026-06-09 06:50:38 -04:00
parent 9ca7d35ce0
commit a789fb131f
7 changed files with 997 additions and 97 deletions
+399 -60
View File
@@ -19,8 +19,23 @@ from utils.config import (
add_isolated_user,
remove_isolated_user,
get_isolated_users,
cast_vote,
get_vote_score,
get_keyword_points,
clear_votes,
add_keyword,
remove_keyword,
get_keywords,
)
# --- Community vote configuration ---
VOTE_XCOM_THRESHOLD = 10 # net score required to auto-XCOM a user
VOTE_ADMIN_WEIGHT = 2 # admins / moderators count double
VOTE_USER_WEIGHT = 1 # regular members
VOTE_COOLDOWN_HOURS = 24 # one vote per voter per target per 24h
VOTE_RESET_DAYS = 7 # tallies reset weekly
VOTE_MIN_UNIQUE_VOTERS = 5 # quorum: distinct voters required before XCOM fires
async def get_or_create_xcom_role(guild: discord.Guild) -> discord.Role:
"""Get existing Excommunicado role or create a new one."""
@@ -72,12 +87,184 @@ async def get_or_create_xcom_channel(guild: discord.Guild, role: discord.Role) -
return channel
def _keyword_list_embed(guild: discord.Guild) -> discord.Embed:
"""Build an embed listing the guild's auto-score keywords."""
keywords = get_keywords(guild.id)
embed = discord.Embed(
title="🚩 Auto-Score Keywords",
description=(
"Messages matching these phrases add points to the sender's community score.\n"
"Reaching the XCOM threshold auto-isolates them — even when no admin is online.\n"
"_Staff and the server owner are immune._"
),
color=discord.Color.orange(),
)
if not keywords:
embed.add_field(name="No keywords yet", value="Use **Add / Edit** to create one.", inline=False)
else:
lines = []
for _, info in sorted(keywords.items(), key=lambda kv: -int(kv[1].get("weight", 0))):
lines.append(f"• `{info.get('display', '?')}` — **+{int(info.get('weight', 1))}**")
# Embed field values cap at 1024 chars; chunk if needed
chunk = ""
idx = 1
for line in lines:
if len(chunk) + len(line) + 1 > 1024:
embed.add_field(name=f"Phrases ({idx})", value=chunk, inline=False)
chunk = ""
idx += 1
chunk += line + "\n"
if chunk:
embed.add_field(name=f"Phrases ({idx})" if idx > 1 else "Phrases", value=chunk, inline=False)
embed.set_footer(text="Curate carefully — this is a cybersecurity channel; some terms are legitimate in context.")
return embed
class KeywordAddModal(discord.ui.Modal, title="Add / Edit Auto-Score Keyword"):
phrase = discord.ui.TextInput(
label="Keyword or phrase",
placeholder="e.g. cracked software, free nitro generator, warez",
max_length=200,
required=True,
)
weight = discord.ui.TextInput(
label="Score weight (integer, e.g. 1-10)",
placeholder="Higher = closer to instant XCOM. Threshold is 10.",
max_length=4,
required=True,
default="1",
)
def __init__(self, parent_view: "KeywordManagerView"):
super().__init__()
self.parent_view = parent_view
async def on_submit(self, interaction: discord.Interaction):
try:
weight_val = int(str(self.weight.value).strip())
except ValueError:
await interaction.response.send_message("Weight must be a whole number.", ephemeral=True)
return
if weight_val < 1 or weight_val > 100:
await interaction.response.send_message("Weight must be between 1 and 100.", ephemeral=True)
return
entry = add_keyword(interaction.guild.id, str(self.phrase.value), weight_val, interaction.user.id)
if entry is None:
await interaction.response.send_message("That phrase was empty after normalization.", ephemeral=True)
return
await interaction.response.edit_message(
embed=_keyword_list_embed(interaction.guild), view=self.parent_view
)
class KeywordRemoveModal(discord.ui.Modal, title="Remove Auto-Score Keyword"):
phrase = discord.ui.TextInput(
label="Keyword or phrase to remove",
placeholder="Type the phrase exactly as listed",
max_length=200,
required=True,
)
def __init__(self, parent_view: "KeywordManagerView"):
super().__init__()
self.parent_view = parent_view
async def on_submit(self, interaction: discord.Interaction):
removed = remove_keyword(interaction.guild.id, str(self.phrase.value))
if not removed:
await interaction.response.send_message("No matching keyword was found.", ephemeral=True)
return
await interaction.response.edit_message(
embed=_keyword_list_embed(interaction.guild), view=self.parent_view
)
class KeywordManagerView(discord.ui.View):
"""Interactive admin panel for curating auto-score keywords."""
def __init__(self, author_id: int):
super().__init__(timeout=180)
self.author_id = author_id
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Only the invoking admin (with Manage Server) may operate the panel.
if interaction.user.id != self.author_id or not interaction.user.guild_permissions.manage_guild:
await interaction.response.send_message(
"Only the admin who opened this panel can use it.", ephemeral=True
)
return False
return True
@discord.ui.button(label="Add / Edit", style=discord.ButtonStyle.success, emoji="")
async def add_edit(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_modal(KeywordAddModal(self))
@discord.ui.button(label="Remove", style=discord.ButtonStyle.danger, emoji="🗑️")
async def remove(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_modal(KeywordRemoveModal(self))
@discord.ui.button(label="Refresh", style=discord.ButtonStyle.secondary, emoji="🔄")
async def refresh(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.edit_message(embed=_keyword_list_embed(interaction.guild), view=self)
class ModerationCog(commands.Cog):
"""XCOM commands and logic."""
def __init__(self, bot: commands.Bot):
self.bot = bot
async def _perform_xcom(self, guild: discord.Guild, user: discord.Member, reason: str, applied_by: str):
"""Shared XCOM logic used by /xcom and the community vote trigger.
Returns (channel, already_isolated). Raises discord.Forbidden if the
Excommunicado role cannot be assigned (caller should handle it).
"""
role = await get_or_create_xcom_role(guild)
channel = await get_or_create_xcom_channel(guild, role)
if role in user.roles:
return channel, True
await user.add_roles(role, reason=f"XCOM ({applied_by}): {reason}")
# Apply permanent XCOM Mark (nickname prefix) - best effort
try:
current_nick = user.nick or user.name
if not current_nick.startswith("[XCOM]"):
new_nick = f"[XCOM] {current_nick}"[:32]
await user.edit(nick=new_nick, reason="XCOM permanent mark applied")
except discord.Forbidden:
pass
timestamp = datetime.now(timezone.utc).isoformat()
add_isolated_user(guild.id, user.id, reason, timestamp)
welcome = discord.Embed(
title="🪐 XCOM Lounge",
description=(
f"**Reason for moderation:** {reason}\n\n"
"You are now in the shared XCOM discussion area with staff.\n"
"Your messages in other channels are being handled privately here.\n"
"This helps keep the main server clean while we resolve the issue.\n\n"
"Staff are present in this room to assist you."
),
color=discord.Color.dark_red()
)
welcome.set_footer(text=f"Isolated at {timestamp[:19]}")
try:
await channel.send(f"{user.mention}", embed=welcome)
except Exception:
pass
try:
await user.send(embed=welcome)
except discord.Forbidden:
pass
return channel, False
@app_commands.command(name="xcom-setup", description="One-time setup: create XCOM role and xcom-lounge shared room")
@app_commands.checks.has_permissions(manage_guild=True)
async def xcom_setup(self, interaction: discord.Interaction):
@@ -106,6 +293,7 @@ class ModerationCog(commands.Cog):
@app_commands.command(name="xcom", description="Excommunicate a user into the shared moderated room (anti-violation)")
@app_commands.checks.has_permissions(manage_roles=True)
@app_commands.checks.cooldown(5, 15.0, key=lambda i: (i.guild_id, i.user.id))
@app_commands.describe(user="The user who violated rules", reason="Reason for XCOM")
async def xcom(self, interaction: discord.Interaction, user: discord.Member, reason: Optional[str] = "Flooding the channel"):
"""Excommunicate the user by assigning the Excommunicado role so they are contained in the xcom-lounge."""
@@ -115,58 +303,42 @@ class ModerationCog(commands.Cog):
if user.id == interaction.user.id:
await interaction.response.send_message("You cannot XCOM yourself.", ephemeral=True)
return
if user.id == interaction.guild.owner_id:
await interaction.response.send_message("You cannot XCOM the server owner.", ephemeral=True)
return
# Role-hierarchy safety: the invoker must outrank the target,
# and the bot must be able to manage the target.
invoker = interaction.user
if isinstance(invoker, discord.Member) and invoker.id != interaction.guild.owner_id:
if user.top_role >= invoker.top_role:
await interaction.response.send_message(
"You cannot XCOM a member with an equal or higher role than yours.",
ephemeral=True,
)
return
me = interaction.guild.me
if me is not None and user.top_role >= me.top_role:
await interaction.response.send_message(
"I cannot XCOM this member — their highest role is above mine. Move my role higher.",
ephemeral=True,
)
return
# Defer the response because this command performs multiple async operations
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
role = await get_or_create_xcom_role(guild)
channel = await get_or_create_xcom_channel(guild, role)
if role in user.roles:
await interaction.response.send_message(f"{user.mention} is already under XCOM.", ephemeral=True)
try:
channel, already = await self._perform_xcom(guild, user, reason, f"by {interaction.user}")
except discord.Forbidden:
await interaction.followup.send("Missing permissions to assign role. Check bot role hierarchy.", ephemeral=True)
return
try:
await user.add_roles(role, reason=f"XCOM by {interaction.user}: {reason}")
except discord.Forbidden:
await interaction.response.send_message("Missing permissions to assign role. Check bot role hierarchy.", ephemeral=True)
if already:
await interaction.followup.send(f"{user.mention} is already under XCOM.", ephemeral=True)
return
# Apply permanent XCOM Mark (nickname prefix) - survives release
try:
current_nick = user.nick or user.name
if not current_nick.startswith("[XCOM]"):
new_nick = f"[XCOM] {current_nick}"[:32] # Discord nick limit
await user.edit(nick=new_nick, reason="XCOM permanent mark applied")
except discord.Forbidden:
pass # Bot lacks permission to change nickname
timestamp = datetime.now(timezone.utc).isoformat()
add_isolated_user(guild.id, user.id, reason, timestamp)
# Welcome message in the isolation channel
welcome = discord.Embed(
title="🪐 XCOM Lounge",
description=(
f"**Reason for moderation:** {reason}\n\n"
"You are now in the shared XCOM discussion area with staff.\n"
"Your messages in other channels are being handled privately here.\n"
"This helps keep the main server clean while we resolve the issue.\n\n"
"Staff are present in this room to assist you."
),
color=discord.Color.dark_red()
)
welcome.set_footer(text=f"Isolated at {timestamp[:19]}")
try:
await channel.send(f"{user.mention}", embed=welcome)
except Exception:
pass
# DM the user
try:
await user.send(embed=welcome)
except discord.Forbidden:
pass # DMs disabled
# Log action
log_embed = discord.Embed(
title="🪐 User XCOM'd",
@@ -179,7 +351,7 @@ class ModerationCog(commands.Cog):
if hasattr(self.bot, "log_action"):
await self.bot.log_action(guild, log_embed)
await interaction.response.send_message(
await interaction.followup.send(
f"{user.mention} has been XCOM'd into the shared room {channel.mention}.\nThey continue to see the server normally.",
ephemeral=True
)
@@ -189,16 +361,21 @@ class ModerationCog(commands.Cog):
@app_commands.describe(user="User to release from XCOM")
async def xcom_release(self, interaction: discord.Interaction, user: discord.Member):
"""Remove the Excommunicado role. The [XCOM] nickname mark stays until an admin runs /xcom-unmark."""
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
data = get_guild_data(guild.id)
role_id = data.get("xcom_role_id")
role = guild.get_role(role_id) if role_id else None
if not role or role not in user.roles:
await interaction.response.send_message(f"{user.mention} is not currently under XCOM.", ephemeral=True)
await interaction.followup.send(f"{user.mention} is not currently under XCOM.", ephemeral=True)
return
await user.remove_roles(role, reason=f"Released from XCOM by {interaction.user}")
try:
await user.remove_roles(role, reason=f"Released from XCOM by {interaction.user}")
except discord.Forbidden:
await interaction.followup.send("Missing permissions to remove role. Check bot role hierarchy.", ephemeral=True)
return
remove_isolated_user(guild.id, user.id)
@@ -209,10 +386,10 @@ class ModerationCog(commands.Cog):
)
try:
await user.send(embed=release_embed)
except:
except discord.HTTPException:
pass
await interaction.response.send_message(
await interaction.followup.send(
f"{user.mention} has been released from XCOM and restored to normal access. The [XCOM] mark remains until an admin removes it.",
ephemeral=True
)
@@ -288,6 +465,7 @@ class ModerationCog(commands.Cog):
@app_commands.command(name="xcom-absolve", description="Check absolution points and release a user early (admin only)")
@app_commands.checks.has_permissions(manage_roles=True)
@app_commands.checks.cooldown(5, 15.0, key=lambda i: (i.guild_id, i.user.id))
@app_commands.describe(user="XCOM user to evaluate for absolution")
async def xcom_absolve(self, interaction: discord.Interaction, user: discord.Member):
"""If the user has enough absolution points, remove role and optionally the mark."""
@@ -300,12 +478,15 @@ class ModerationCog(commands.Cog):
await interaction.response.send_message(f"{user.mention} is not under XCOM.", ephemeral=True)
return
# Defer because this command performs async operations
await interaction.response.defer(ephemeral=True)
isolated = get_isolated_users(guild.id)
user_data = isolated.get(str(user.id), {})
points = user_data.get("absolution_points", 0)
if points < 10: # Threshold for absolution
await interaction.response.send_message(
await interaction.followup.send(
f"{user.mention} has {points} absolution points (need 10). Not ready for absolution yet.",
ephemeral=True
)
@@ -317,7 +498,7 @@ class ModerationCog(commands.Cog):
# Optional: remove from isolated list but keep the mark flag
remove_isolated_user(guild.id, user.id)
await interaction.response.send_message(
await interaction.followup.send(
f"{user.mention} has been absolved ({points} points). The [XCOM] mark remains until manually removed.",
ephemeral=True
)
@@ -348,6 +529,143 @@ class ModerationCog(commands.Cog):
embed.set_footer(text=f"Total: {len(isolated)} | Use /xcom-release to restore access")
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(name="xcom-vote", description="Cast a community vote to excommunicate (up) or defend (down) a user")
@app_commands.checks.cooldown(10, 60.0, key=lambda i: (i.guild_id, i.user.id))
@app_commands.describe(user="The user to vote on", direction="up = excommunicate, down = defend")
@app_commands.choices(direction=[
app_commands.Choice(name="up (excommunicate)", value="up"),
app_commands.Choice(name="down (defend)", value="down"),
])
async def xcom_vote(self, interaction: discord.Interaction, user: discord.Member, direction: app_commands.Choice[str]):
"""Community-driven XCOM. Net score >= threshold (with quorum) auto-isolates the target."""
guild = interaction.guild
voter = interaction.user
# --- Guardrails ---
if user.bot:
await interaction.response.send_message("You cannot vote on bots.", ephemeral=True)
return
if user.id == voter.id:
await interaction.response.send_message("You cannot vote on yourself.", ephemeral=True)
return
if user.id == guild.owner_id:
await interaction.response.send_message("You cannot vote against the server owner.", ephemeral=True)
return
# Staff / moderator immunity
if user.guild_permissions.manage_roles or user.guild_permissions.administrator:
await interaction.response.send_message("You cannot vote against staff or moderators.", ephemeral=True)
return
data = get_guild_data(guild.id)
role_id = data.get("xcom_role_id")
role = guild.get_role(role_id) if role_id else None
if role and role in user.roles:
await interaction.response.send_message(f"{user.mention} is already under XCOM.", ephemeral=True)
return
# The bot must be able to enforce the result
me = guild.me
if me is not None and user.top_role >= me.top_role:
await interaction.response.send_message(
"That member ranks above me, so a community XCOM could not be enforced.", ephemeral=True
)
return
await interaction.response.defer(ephemeral=True)
is_staff = voter.guild_permissions.administrator or voter.guild_permissions.manage_roles
weight = VOTE_ADMIN_WEIGHT if is_staff else VOTE_USER_WEIGHT
dir_val = 1 if direction.value == "up" else -1
result = cast_vote(
guild.id, user.id, voter.id, dir_val, weight,
cooldown_hours=VOTE_COOLDOWN_HOURS, reset_days=VOTE_RESET_DAYS,
)
if not result["ok"]:
if result["reason"] == "cooldown":
await interaction.followup.send(
f"⏳ You've already voted on this user. You can vote again in ~{result['retry_hours']:.1f}h "
"(one vote per user per 24h).",
ephemeral=True,
)
else:
await interaction.followup.send("Your vote could not be recorded.", ephemeral=True)
return
score = result["score"]
unique_voters = result["unique_voters"]
# --- Threshold reached: trigger the community XCOM ---
if score >= VOTE_XCOM_THRESHOLD and unique_voters >= VOTE_MIN_UNIQUE_VOTERS:
try:
channel, already = await self._perform_xcom(
guild, user, "Community vote threshold reached", "community vote"
)
except discord.Forbidden:
await interaction.followup.send(
"Your vote was recorded, but I lack permission to XCOM this user.", ephemeral=True
)
return
clear_votes(guild.id, user.id)
log_embed = discord.Embed(
title="🪐 Community XCOM Triggered",
description=f"{user.mention} ({user.id}) reached the community vote threshold and was placed in {channel.mention}.",
color=discord.Color.dark_red(),
timestamp=datetime.now(timezone.utc),
)
log_embed.add_field(name="Final score", value=str(score), inline=True)
log_embed.add_field(name="Distinct voters", value=str(unique_voters), inline=True)
if hasattr(self.bot, "log_action"):
await self.bot.log_action(guild, log_embed)
await interaction.followup.send(
f"✅ The community has spoken — the score reached **{score}** and the user has been excommunicated.",
ephemeral=True,
)
return
# --- Not yet ---
arrow = "⬆️" if dir_val > 0 else "⬇️"
remaining = VOTE_XCOM_THRESHOLD - score
progress = f"{remaining} more to trigger XCOM" if remaining > 0 else ""
quorum_note = (
"" if unique_voters >= VOTE_MIN_UNIQUE_VOTERS
else f"\n_Needs ≥{VOTE_MIN_UNIQUE_VOTERS} distinct voters (currently {unique_voters})._"
)
await interaction.followup.send(
f"{arrow} Vote recorded. Current score: **{score}/{VOTE_XCOM_THRESHOLD}**{progress}."
f"{quorum_note}\n_Tallies reset weekly. One vote per user per 24h._",
ephemeral=True,
)
@app_commands.command(name="xcom-votes", description="View the current community vote tally for a user (staff)")
@app_commands.checks.has_permissions(manage_roles=True)
@app_commands.describe(user="User to inspect")
async def xcom_votes(self, interaction: discord.Interaction, user: discord.Member):
"""Show the running community vote score and quorum status for a user."""
score, unique_voters = get_vote_score(interaction.guild.id, user.id, reset_days=VOTE_RESET_DAYS)
kw_points = get_keyword_points(interaction.guild.id, user.id, reset_days=VOTE_RESET_DAYS)
embed = discord.Embed(title="🗳️ Community Vote Standing", color=discord.Color.orange())
embed.add_field(name="Target", value=user.mention, inline=True)
embed.add_field(name="Score", value=f"{score}/{VOTE_XCOM_THRESHOLD}", inline=True)
embed.add_field(name="Distinct voters", value=f"{unique_voters}/{VOTE_MIN_UNIQUE_VOTERS}", inline=True)
embed.add_field(name="From human votes", value=str(score - kw_points), inline=True)
embed.add_field(name="From keyword flags", value=str(kw_points), inline=True)
embed.set_footer(text=f"Threshold {VOTE_XCOM_THRESHOLD} • quorum {VOTE_MIN_UNIQUE_VOTERS} • resets weekly")
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(name="xcom-keywords", description="Manage auto-score keywords/phrases (admin panel)")
@app_commands.checks.has_permissions(manage_guild=True)
async def xcom_keywords(self, interaction: discord.Interaction):
"""Open the interactive panel to add, edit, or remove auto-score keywords."""
view = KeywordManagerView(interaction.user.id)
await interaction.response.send_message(
embed=_keyword_list_embed(interaction.guild), view=view, ephemeral=True
)
@xcom.error
@xcom_release.error
@xcom_unmark.error
@@ -356,14 +674,35 @@ class ModerationCog(commands.Cog):
@xcom_absolve.error
@xcom_setup.error
@xcom_list.error
@xcom_vote.error
@xcom_votes.error
@xcom_keywords.error
async def permission_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
"You need the 'Manage Roles' (or Manage Server for setup) permission to use this command.",
ephemeral=True
)
else:
await interaction.response.send_message(f"Error: {error}", ephemeral=True)
try:
if isinstance(error, app_commands.MissingPermissions):
if not interaction.response.is_done():
await interaction.response.send_message(
"You need the 'Manage Roles' (or Manage Server for setup) permission to use this command.",
ephemeral=True
)
else:
await interaction.followup.send(
"You need the 'Manage Roles' (or Manage Server for setup) permission to use this command.",
ephemeral=True
)
elif isinstance(error, app_commands.CommandOnCooldown):
msg = f"⏳ Slow down — try again in {error.retry_after:.1f}s."
if not interaction.response.is_done():
await interaction.response.send_message(msg, ephemeral=True)
else:
await interaction.followup.send(msg, ephemeral=True)
else:
if not interaction.response.is_done():
await interaction.response.send_message(f"Error: {error}", ephemeral=True)
else:
await interaction.followup.send(f"Error: {error}", ephemeral=True)
except discord.errors.InteractionResponded:
pass # Interaction was already responded to
async def setup(bot: commands.Bot):