EveCogs/nexuscore/suggestions.py
everestmcarthur a76758f61a feat: NexusCore v1.0.0 — Ultimate all-in-one server management cog
8 fully-featured subsystems, 5,673 lines of code, 100+ commands:

🎫 Tickets — Panel-based creation, categories with modal questions, staff claim,
   priority system (low/med/high/urgent), HTML transcripts, feedback ratings, DM notifs

📋 Applications — Multi-type forms with paginated modals, accept/deny/interview
   review, auto-role, staff notes, auto-thread, cooldowns, age requirements

💡 Suggestions — Button voting, 8 status types, anonymous mode, auto-thread,
   staff responses, categories, top leaderboard, status-specific channels

🎭 Reaction Roles — Button/select/reaction modes, exclusive groups, role
   requirements, sticky roles, temporary roles, max picks, DM confirm

🎉 Giveaways — Button entry, role/age requirements, bonus entries, drop mode,
   multiple winners, reroll, scheduled end, DM notifications

📋 Logging — 20+ event types, per-event channels, message cache, invite tracking,
   ignore filters (channel/role/user/bot), bulk delete file export

🛡️ Moderation — Case system, warn auto-escalation, mute/kick/ban/softban/tempban,
   notes, channel/server lockdown, anti-raid, anti-nuke, automod (6 modules), appeals

🪙 Economy — Wallet+bank, daily/weekly streaks, work/crime/rob, coinflip/slots/blackjack,
   shop (items+roles), inventory, heist mini-game, pet system (6 types), leaderboard
2026-05-07 23:51:53 +00:00

371 lines
15 KiB
Python

"""NexusCore — Suggestion system with voting, statuses, threads, anonymous mode."""
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": {},
# id -> {user_id, content, status, upvotes, downvotes, submitted_at,
# message_id, staff_response, anonymous, image_url, category, thread_id}
"categories": [],
"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": [],
}
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)),
}
# ── 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 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."""
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,
"anonymous": anonymous,
"image_url": image_url,
"category": category,
"thread_id": thread_id,
}
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 the 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
async def _set_status(self, interaction: discord.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:
return await interaction.response.send_message("Not found.", ephemeral=True)
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
await interaction.response.send_message(
embed=ok_embed(f"Suggestion #{suggestion_id} → **{emoji} {label}**")
)
# Move to status-specific 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,
))