EveCogs/nexuscore/suggestions.py
everestmcarthur 84459daf63 NexusCore v2.0.0 — 10 modules, 100+ commands, 8,132 lines
Major expansion:
- All 8 modules fully expanded with advanced features
- New: Embed Builder (webhook-based, templates, scheduling, variables)
- New: Dashboard Integration (web config for all modules)
- Serverlog: 30+ event types, invite tracking, per-event channels
- Moderation: quarantine, reputation, warning decay, staff leaderboard,
  anti-raid, anti-nuke, 6 automod modules, appeals, cross-server bans
- Economy: roulette, dice, fishing, mining, crafting, achievements,
  income roles, auction house, pets (6 types), heists, gambling stats
- Tickets: auto-close, tags, HTML transcripts, staff tracking, reopen
- Applications: staff voting, bulk actions, webhook notifications, templates
- Suggestions: edit, merge, 10 statuses, tag system, auto-approve/deny
- Reaction Roles: verification panels, exclusive groups, sticky/temp roles
- Giveaways: drop mode, bonus entries, templates, DM notifications
- All views use custom_id for persistence across restarts
- Full syntax verification on all 13 Python files
2026-05-08 02:38:34 +00:00

501 lines
21 KiB
Python

"""NexusCore — Suggestion system v2: voting, statuses, threads, anonymous, edit by author,
merge duplicates, tags, polls, stats, auto-approve threshold, staff responses."""
from __future__ import annotations
import datetime
from typing import Optional
import discord
from redbot.core import Config, commands
from .utils import (
Clr, ok_embed, err_embed, info_embed, short_id, ts_now, ts_relative,
safe_send, safe_dm, Paginator, chunk_list,
)
# ── Defaults ───────────────────────────────────────────────────────────────
SUGGEST_DEFAULTS_GUILD = {
"enabled": False,
"channel": None,
"log_channel": None,
"approved_channel": None,
"denied_channel": None,
"implemented_channel": None,
"counter": 0,
"suggestions": {},
"categories": [],
"tags": [], # pre-defined tags
"anonymous_allowed": True,
"auto_thread": True,
"auto_upvote": True,
"auto_downvote": True,
"upvote_emoji": "👍",
"downvote_emoji": "👎",
"dm_on_status": True,
"cooldown": 60,
"min_length": 10,
"max_length": 2000,
"blacklisted": [],
"require_category": False,
"allow_images": True,
"voting_buttons": True,
"staff_roles": [],
"allow_edit": True,
"edit_window": 300, # seconds user can edit after submission
"auto_approve_threshold": 0, # auto-approve at N upvotes (0=disabled)
"auto_deny_threshold": 0,
}
STATUS_MAP = {
"pending": ("⏳", "Pending", Clr.SUGGEST),
"approved": ("✅", "Approved", Clr.SUCCESS),
"denied": ("❌", "Denied", Clr.ERROR),
"considered": ("🤔", "Under Consideration", Clr.INFO),
"implemented": ("🚀", "Implemented", discord.Colour(0x9B59B6)),
"in_progress": ("🔨", "In Progress", discord.Colour(0xE67E22)),
"duplicate": ("♻️", "Duplicate", discord.Colour(0x95A5A6)),
"invalid": ("🚫", "Invalid", discord.Colour(0x7F8C8D)),
"planned": ("📅", "Planned", discord.Colour(0x3498DB)),
"on_hold": ("⏸️", "On Hold", discord.Colour(0xBDC3C7)),
}
# ── Views ──────────────────────────────────────────────────────────────────
class SuggestionVoteView(discord.ui.View):
def __init__(self, cog: "SuggestionsMixin", suggestion_id: str):
super().__init__(timeout=None)
self.cog = cog
self.suggestion_id = suggestion_id
@discord.ui.button(label="0", style=discord.ButtonStyle.success, emoji="👍", custom_id="nexus_suggest_up")
async def upvote(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._handle_vote(interaction, self.suggestion_id, "up")
@discord.ui.button(label="0", style=discord.ButtonStyle.danger, emoji="👎", custom_id="nexus_suggest_down")
async def downvote(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._handle_vote(interaction, self.suggestion_id, "down")
class SuggestModal(discord.ui.Modal):
def __init__(self, cog, category: str | None = None, anonymous: bool = False):
super().__init__(title="Submit a Suggestion")
self.cog = cog
self.category = category
self.anonymous = anonymous
self.suggestion_input = discord.ui.TextInput(
label="Your Suggestion", style=discord.TextStyle.paragraph,
placeholder="Describe your idea in detail...", min_length=10, max_length=2000, required=True,
)
self.image_input = discord.ui.TextInput(
label="Image URL (optional)", style=discord.TextStyle.short,
placeholder="https://...", required=False, max_length=500,
)
self.add_item(self.suggestion_input)
self.add_item(self.image_input)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
await self.cog._create_suggestion(
interaction, self.suggestion_input.value,
category=self.category, anonymous=self.anonymous,
image_url=self.image_input.value or None,
)
await interaction.followup.send("✅ Suggestion submitted!", ephemeral=True)
class EditSuggestionModal(discord.ui.Modal):
def __init__(self, cog, suggestion_id: str, current_content: str):
super().__init__(title=f"Edit Suggestion #{suggestion_id}")
self.cog = cog
self.suggestion_id = suggestion_id
self.content = discord.ui.TextInput(
label="Updated suggestion", style=discord.TextStyle.paragraph,
default=current_content, min_length=10, max_length=2000,
)
self.add_item(self.content)
async def on_submit(self, interaction: discord.Interaction):
await self.cog._edit_suggestion(interaction, self.suggestion_id, self.content.value)
class SuggestPanelView(discord.ui.View):
def __init__(self, cog: "SuggestionsMixin"):
super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="💡 Suggest", style=discord.ButtonStyle.primary, custom_id="nexus_suggest_panel")
async def suggest_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
data = await self.cog.suggest_config.guild(interaction.guild).all()
if not data["enabled"]:
return await interaction.response.send_message("Suggestions are disabled.", ephemeral=True)
if str(interaction.user.id) in [str(x) for x in data["blacklisted"]]:
return await interaction.response.send_message("You are blacklisted from suggestions.", ephemeral=True)
cats = data["categories"]
if cats and data["require_category"]:
view = SuggestCategoryView(self.cog, cats)
return await interaction.response.send_message("Choose a category:", view=view, ephemeral=True)
modal = SuggestModal(self.cog)
await interaction.response.send_modal(modal)
@discord.ui.button(label="🕵️ Anonymous", style=discord.ButtonStyle.secondary, custom_id="nexus_suggest_anon")
async def anon_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
data = await self.cog.suggest_config.guild(interaction.guild).all()
if not data["anonymous_allowed"]:
return await interaction.response.send_message("Anonymous suggestions are disabled.", ephemeral=True)
modal = SuggestModal(self.cog, anonymous=True)
await interaction.response.send_modal(modal)
class SuggestCategoryView(discord.ui.View):
def __init__(self, cog, categories: list[str]):
super().__init__(timeout=60)
self.cog = cog
options = [discord.SelectOption(label=c.title(), value=c) for c in categories[:25]]
self.sel = discord.ui.Select(placeholder="Category...", options=options)
self.sel.callback = self.on_select
self.add_item(self.sel)
async def on_select(self, interaction: discord.Interaction):
modal = SuggestModal(self.cog, category=self.sel.values[0])
await interaction.response.send_modal(modal)
class StatusSelectView(discord.ui.View):
def __init__(self, cog, suggestion_id: str):
super().__init__(timeout=60)
self.cog = cog
options = [discord.SelectOption(label=v[1], value=k, emoji=v[0]) for k, v in STATUS_MAP.items()]
self.sel = discord.ui.Select(placeholder="Set status...", options=options)
self.sel.callback = self.on_select
self.add_item(self.sel)
self.suggestion_id = suggestion_id
async def on_select(self, interaction: discord.Interaction):
await self.cog._set_status(interaction, self.suggestion_id, self.sel.values[0])
# ── Mixin ──────────────────────────────────────────────────────────────────
class SuggestionsMixin:
"""Suggestion system mixin — v2 with edit, merge, tags, stats, auto-approve."""
def _init_suggestions(self, bot):
self.suggest_config = Config.get_conf(None, identifier=900003, cog_name="NexusCoreSuggestions")
self.suggest_config.register_guild(**SUGGEST_DEFAULTS_GUILD)
self._suggest_panel_view = SuggestPanelView(self)
bot.add_view(self._suggest_panel_view)
async def _create_suggestion(
self, interaction: discord.Interaction, content: str, *,
category: str | None = None, anonymous: bool = False, image_url: str | None = None,
):
guild = interaction.guild
conf = self.suggest_config.guild(guild)
data = await conf.all()
counter = data["counter"] + 1
await conf.counter.set(counter)
channel = guild.get_channel(data["channel"])
if not channel:
return
s_id = str(counter)
emoji, label, colour = STATUS_MAP["pending"]
embed = discord.Embed(
title=f"Suggestion #{counter}", description=content, colour=colour,
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
if anonymous:
embed.set_author(name="Anonymous")
else:
embed.set_author(name=str(interaction.user),
icon_url=interaction.user.display_avatar.url if interaction.user.display_avatar else None)
if category:
embed.add_field(name="Category", value=category.title(), inline=True)
embed.add_field(name="Status", value=f"{emoji} {label}", inline=True)
embed.add_field(name="Votes", value="👍 0 | 👎 0", inline=True)
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text=f"ID: {s_id}")
if data["voting_buttons"]:
view = SuggestionVoteView(self, s_id)
msg = await channel.send(embed=embed, view=view)
else:
msg = await channel.send(embed=embed)
if data["auto_upvote"]:
await msg.add_reaction(data["upvote_emoji"])
if data["auto_downvote"]:
await msg.add_reaction(data["downvote_emoji"])
thread_id = None
if data["auto_thread"]:
try:
thread = await msg.create_thread(name=f"Discussion — Suggestion #{counter}")
thread_id = thread.id
except discord.HTTPException:
pass
suggestion = {
"user_id": interaction.user.id,
"content": content,
"status": "pending",
"upvotes": [],
"downvotes": [],
"submitted_at": ts_now(),
"message_id": msg.id,
"staff_response": None,
"staff_responses": [],
"anonymous": anonymous,
"image_url": image_url,
"category": category,
"thread_id": thread_id,
"tags": [],
"edit_history": [],
"merged_with": None,
}
async with conf.suggestions() as subs:
subs[s_id] = suggestion
async def _handle_vote(self, interaction: discord.Interaction, suggestion_id: str, direction: str):
conf = self.suggest_config.guild(interaction.guild)
async with conf.suggestions() as subs:
s = subs.get(suggestion_id)
if not s:
return await interaction.response.send_message("Suggestion not found.", ephemeral=True)
uid = interaction.user.id
ups = s.get("upvotes", [])
downs = s.get("downvotes", [])
if direction == "up":
if uid in ups:
ups.remove(uid)
else:
ups.append(uid)
if uid in downs:
downs.remove(uid)
else:
if uid in downs:
downs.remove(uid)
else:
downs.append(uid)
if uid in ups:
ups.remove(uid)
s["upvotes"] = ups
s["downvotes"] = downs
subs[suggestion_id] = s
await interaction.response.send_message(f"Vote recorded! 👍 {len(ups)} | 👎 {len(downs)}", ephemeral=True)
# Update embed
data = await conf.all()
channel = interaction.guild.get_channel(data["channel"])
if channel and s.get("message_id"):
try:
msg = await channel.fetch_message(s["message_id"])
if msg.embeds:
embed = msg.embeds[0]
for i, field in enumerate(embed.fields):
if field.name == "Votes":
embed.set_field_at(i, name="Votes", value=f"👍 {len(ups)} | 👎 {len(downs)}", inline=True)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
# Auto-approve/deny
auto_approve = data.get("auto_approve_threshold", 0)
auto_deny = data.get("auto_deny_threshold", 0)
if auto_approve and len(ups) >= auto_approve and s.get("status") == "pending":
class _FI:
guild = interaction.guild
user = interaction.guild.me
response = type("R", (), {"send_message": lambda s, *a, **k: None})()
await self._set_status(_FI(), suggestion_id, "approved")
elif auto_deny and len(downs) >= auto_deny and s.get("status") == "pending":
class _FI:
guild = interaction.guild
user = interaction.guild.me
response = type("R", (), {"send_message": lambda s, *a, **k: None})()
await self._set_status(_FI(), suggestion_id, "denied")
async def _edit_suggestion(self, interaction, suggestion_id: str, new_content: str):
"""Allow the original author to edit within the edit window."""
conf = self.suggest_config.guild(interaction.guild)
data = await conf.all()
s = data["suggestions"].get(suggestion_id)
if not s:
return await interaction.response.send_message("Not found.", ephemeral=True)
if s["user_id"] != interaction.user.id:
return await interaction.response.send_message("You can only edit your own suggestions.", ephemeral=True)
if not data.get("allow_edit"):
return await interaction.response.send_message("Editing is disabled.", ephemeral=True)
window = data.get("edit_window", 300)
if ts_now() - s["submitted_at"] > window:
return await interaction.response.send_message(f"Edit window ({window}s) has passed.", ephemeral=True)
async with conf.suggestions() as subs:
if suggestion_id in subs:
subs[suggestion_id]["edit_history"].append({
"old": subs[suggestion_id]["content"],
"edited_at": ts_now(),
})
subs[suggestion_id]["content"] = new_content
# Update embed
channel = interaction.guild.get_channel(data["channel"])
if channel and s.get("message_id"):
try:
msg = await channel.fetch_message(s["message_id"])
if msg.embeds:
embed = msg.embeds[0]
embed.description = new_content
embed.set_footer(text=f"ID: {suggestion_id} · Edited")
await msg.edit(embed=embed)
except discord.HTTPException:
pass
await interaction.response.send_message("✅ Suggestion updated.", ephemeral=True)
async def _merge_suggestions(self, guild, target_id: str, source_id: str) -> bool:
"""Merge source suggestion into target."""
conf = self.suggest_config.guild(guild)
data = await conf.all()
target = data["suggestions"].get(target_id)
source = data["suggestions"].get(source_id)
if not target or not source:
return False
async with conf.suggestions() as subs:
# Merge votes (no duplicates)
for uid in source.get("upvotes", []):
if uid not in subs[target_id].get("upvotes", []):
subs[target_id].setdefault("upvotes", []).append(uid)
for uid in source.get("downvotes", []):
if uid not in subs[target_id].get("downvotes", []):
subs[target_id].setdefault("downvotes", []).append(uid)
subs[source_id]["status"] = "duplicate"
subs[source_id]["merged_with"] = target_id
# Update source embed
channel = guild.get_channel(data["channel"])
if channel and source.get("message_id"):
try:
msg = await channel.fetch_message(source["message_id"])
if msg.embeds:
embed = msg.embeds[0]
embed.colour = discord.Colour(0x95A5A6)
for i, field in enumerate(embed.fields):
if field.name == "Status":
embed.set_field_at(i, name="Status", value="♻️ Duplicate (merged)", inline=True)
embed.add_field(name="Merged into", value=f"Suggestion #{target_id}", inline=True)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
return True
async def _tag_suggestion(self, guild, suggestion_id: str, tag: str) -> bool:
"""Add a tag to a suggestion."""
conf = self.suggest_config.guild(guild)
async with conf.suggestions() as subs:
s = subs.get(suggestion_id)
if not s:
return False
s.setdefault("tags", [])
if tag not in s["tags"]:
s["tags"].append(tag)
subs[suggestion_id] = s
return True
async def _set_status(self, interaction, suggestion_id: str, status: str):
guild = interaction.guild
conf = self.suggest_config.guild(guild)
data = await conf.all()
s = data["suggestions"].get(suggestion_id)
if not s:
try:
return await interaction.response.send_message("Not found.", ephemeral=True)
except Exception:
return
async with conf.suggestions() as subs:
if suggestion_id in subs:
subs[suggestion_id]["status"] = status
emoji, label, colour = STATUS_MAP.get(status, ("❓", status, Clr.INFO))
channel = guild.get_channel(data["channel"])
if channel and s.get("message_id"):
try:
msg = await channel.fetch_message(s["message_id"])
if msg.embeds:
embed = msg.embeds[0]
embed.colour = colour
for i, field in enumerate(embed.fields):
if field.name == "Status":
embed.set_field_at(i, name="Status", value=f"{emoji} {label}", inline=True)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
try:
await interaction.response.send_message(embed=ok_embed(f"Suggestion #{suggestion_id} → **{emoji} {label}**"))
except Exception:
pass
# Move to status channel if set
move_map = {
"approved": data.get("approved_channel"),
"denied": data.get("denied_channel"),
"implemented": data.get("implemented_channel"),
}
move_ch_id = move_map.get(status)
if move_ch_id:
move_ch = guild.get_channel(move_ch_id)
if move_ch and channel and s.get("message_id"):
try:
old_msg = await channel.fetch_message(s["message_id"])
new_embed = old_msg.embeds[0] if old_msg.embeds else discord.Embed(description=s["content"])
new_msg = await move_ch.send(embed=new_embed)
async with conf.suggestions() as subs:
if suggestion_id in subs:
subs[suggestion_id]["message_id"] = new_msg.id
except discord.HTTPException:
pass
# DM user
if data["dm_on_status"] and not s.get("anonymous"):
user = guild.get_member(s["user_id"])
if user:
await safe_dm(user, embed=discord.Embed(
description=f"Your suggestion **#{suggestion_id}** in **{guild.name}** has been updated to: {emoji} **{label}**",
colour=colour,
))
def _get_suggestion_stats(self, data: dict) -> dict:
"""Compute suggestion statistics."""
subs = data.get("suggestions", {})
total = len(subs)
by_status = {}
by_category = {}
total_upvotes = 0
total_downvotes = 0
for s in subs.values():
st = s.get("status", "pending")
by_status[st] = by_status.get(st, 0) + 1
cat = s.get("category") or "uncategorized"
by_category[cat] = by_category.get(cat, 0) + 1
total_upvotes += len(s.get("upvotes", []))
total_downvotes += len(s.get("downvotes", []))
return {
"total": total,
"by_status": by_status,
"by_category": by_category,
"total_upvotes": total_upvotes,
"total_downvotes": total_downvotes,
}