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
This commit is contained in:
parent
a76758f61a
commit
84459daf63
13 changed files with 4974 additions and 2512 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
|||
wormhole/__pycache__/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.bak
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""NexusCore — Application / forms system with modals, review, multi-type, auto-role."""
|
||||
"""NexusCore — Application / forms system v2: modals, review, multi-type, auto-role,
|
||||
conditional questions, voting, templates, stats, bulk actions, webhook notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -18,26 +19,24 @@ APP_DEFAULTS_GUILD = {
|
|||
"enabled": False,
|
||||
"review_channel": None,
|
||||
"log_channel": None,
|
||||
"webhook_url": None,
|
||||
"types": {},
|
||||
# type_name -> {
|
||||
# description, emoji, questions: [{label, placeholder, style, required, max_length}],
|
||||
# role_on_accept, role_on_deny, dm_on_accept, dm_on_deny,
|
||||
# accept_msg, deny_msg, review_roles, cooldown, max_pending,
|
||||
# auto_thread, require_account_age_days, require_server_days,
|
||||
# enabled, panel_channel, panel_message
|
||||
# }
|
||||
"templates": {}, # template_name -> {questions, description, role_on_accept, ...}
|
||||
"submissions": {},
|
||||
# sub_id -> {user_id, type, answers, status, submitted_at, reviewed_by, reviewed_at, review_msg_id, notes}
|
||||
"blacklisted": [],
|
||||
"global_cooldown": 86400,
|
||||
"dm_results": True,
|
||||
"anonymous_review": False,
|
||||
"voting_enabled": False,
|
||||
"voting_threshold": 3,
|
||||
"auto_accept_votes": 0, # 0 = disabled
|
||||
"auto_deny_votes": 0,
|
||||
"stats": {"total": 0, "accepted": 0, "denied": 0, "avg_review_time": 0},
|
||||
}
|
||||
|
||||
|
||||
# ── Views ──────────────────────────────────────────────────────────────────
|
||||
class AppPanelView(discord.ui.View):
|
||||
"""Persistent panel with a button per application type."""
|
||||
def __init__(self, cog: "ApplicationsMixin"):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
|
|
@ -67,8 +66,7 @@ class AppTypeSelectView(discord.ui.View):
|
|||
options = []
|
||||
for name, td in list(types.items())[:25]:
|
||||
options.append(discord.SelectOption(
|
||||
label=name.title(),
|
||||
value=name,
|
||||
label=name.title(), value=name,
|
||||
description=(td.get("description", "") or "")[:100],
|
||||
emoji=td.get("emoji") or "📋",
|
||||
))
|
||||
|
|
@ -81,7 +79,6 @@ class AppTypeSelectView(discord.ui.View):
|
|||
|
||||
|
||||
class AppFormModal(discord.ui.Modal):
|
||||
"""Modal with up to 5 questions."""
|
||||
def __init__(self, cog, app_type: str, questions: list[dict], page: int = 0):
|
||||
super().__init__(title=f"Application — {app_type.title()}"[:45])
|
||||
self.cog = cog
|
||||
|
|
@ -127,7 +124,6 @@ class AppFormModal(discord.ui.Modal):
|
|||
|
||||
|
||||
class ReviewView(discord.ui.View):
|
||||
"""Accept / Deny / Interview buttons on a review embed."""
|
||||
def __init__(self, cog: "ApplicationsMixin", sub_id: str):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
|
|
@ -150,6 +146,14 @@ class ReviewView(discord.ui.View):
|
|||
modal = AppNoteModal(self.cog, self.sub_id)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
@discord.ui.button(label="👍", style=discord.ButtonStyle.secondary, custom_id="nexus_app_vote_up")
|
||||
async def vote_up(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self.cog._vote_application(interaction, self.sub_id, "up")
|
||||
|
||||
@discord.ui.button(label="👎", style=discord.ButtonStyle.secondary, custom_id="nexus_app_vote_down")
|
||||
async def vote_down(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self.cog._vote_application(interaction, self.sub_id, "down")
|
||||
|
||||
|
||||
class AppNoteModal(discord.ui.Modal):
|
||||
def __init__(self, cog, sub_id: str):
|
||||
|
|
@ -169,20 +173,49 @@ class AppNoteModal(discord.ui.Modal):
|
|||
"text": self.note_input.value,
|
||||
"at": ts_now(),
|
||||
})
|
||||
await interaction.response.send_message(f"📝 Note added.", ephemeral=True)
|
||||
await interaction.response.send_message("📝 Note added.", ephemeral=True)
|
||||
|
||||
|
||||
class BulkActionView(discord.ui.View):
|
||||
"""View for bulk accept/deny multiple apps at once."""
|
||||
def __init__(self, cog, sub_ids: list[str]):
|
||||
super().__init__(timeout=120)
|
||||
self.cog = cog
|
||||
self.sub_ids = sub_ids
|
||||
|
||||
@discord.ui.button(label="Accept All", style=discord.ButtonStyle.success, emoji="✅")
|
||||
async def accept_all(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
count = 0
|
||||
for sid in self.sub_ids:
|
||||
try:
|
||||
await self.cog._review_application(interaction, sid, "accepted", silent=True)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
await interaction.response.send_message(f"✅ Bulk accepted {count}/{len(self.sub_ids)} applications.", ephemeral=True)
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button(label="Deny All", style=discord.ButtonStyle.danger, emoji="❌")
|
||||
async def deny_all(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
count = 0
|
||||
for sid in self.sub_ids:
|
||||
try:
|
||||
await self.cog._review_application(interaction, sid, "denied", silent=True)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
await interaction.response.send_message(f"❌ Bulk denied {count}/{len(self.sub_ids)} applications.", ephemeral=True)
|
||||
self.stop()
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class ApplicationsMixin:
|
||||
"""Application system mixin."""
|
||||
"""Application system mixin — v2 with voting, templates, bulk actions, webhook."""
|
||||
|
||||
def _init_applications(self, bot):
|
||||
self.app_config = Config.get_conf(
|
||||
None, identifier=900002, cog_name="NexusCoreApps"
|
||||
)
|
||||
self.app_config = Config.get_conf(None, identifier=900002, cog_name="NexusCoreApps")
|
||||
self.app_config.register_guild(**APP_DEFAULTS_GUILD)
|
||||
self._app_answer_cache = {}
|
||||
|
||||
self._app_panel_view = AppPanelView(self)
|
||||
bot.add_view(self._app_panel_view)
|
||||
|
||||
|
|
@ -197,7 +230,6 @@ class ApplicationsMixin:
|
|||
if not type_data or not type_data.get("enabled", True):
|
||||
return await interaction.response.send_message("This application type is unavailable.", ephemeral=True)
|
||||
|
||||
# Cooldown check
|
||||
cooldown = type_data.get("cooldown") or data["global_cooldown"]
|
||||
for sub in data["submissions"].values():
|
||||
if str(sub["user_id"]) == str(interaction.user.id) and sub["type"] == type_name:
|
||||
|
|
@ -206,26 +238,19 @@ class ApplicationsMixin:
|
|||
if ts_now() - sub.get("submitted_at", 0) < cooldown:
|
||||
remaining = cooldown - (ts_now() - sub.get("submitted_at", 0))
|
||||
return await interaction.response.send_message(
|
||||
f"Cooldown active. Try again {ts_relative(ts_now() + remaining)}.", ephemeral=True
|
||||
)
|
||||
f"Cooldown active. Try again {ts_relative(ts_now() + remaining)}.", ephemeral=True)
|
||||
|
||||
# Account age check
|
||||
req_age = type_data.get("require_account_age_days", 0)
|
||||
if req_age:
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - interaction.user.created_at).days
|
||||
if age < req_age:
|
||||
return await interaction.response.send_message(
|
||||
f"Your account must be at least {req_age} days old.", ephemeral=True
|
||||
)
|
||||
return await interaction.response.send_message(f"Your account must be at least {req_age} days old.", ephemeral=True)
|
||||
|
||||
# Server membership check
|
||||
req_server = type_data.get("require_server_days", 0)
|
||||
if req_server and interaction.user.joined_at:
|
||||
days = (datetime.datetime.now(datetime.timezone.utc) - interaction.user.joined_at).days
|
||||
if days < req_server:
|
||||
return await interaction.response.send_message(
|
||||
f"You must be in the server for at least {req_server} days.", ephemeral=True
|
||||
)
|
||||
return await interaction.response.send_message(f"You must be in the server for at least {req_server} days.", ephemeral=True)
|
||||
|
||||
questions = type_data.get("questions", [])
|
||||
if not questions:
|
||||
|
|
@ -251,11 +276,17 @@ class ApplicationsMixin:
|
|||
"reviewed_at": None,
|
||||
"review_msg_id": None,
|
||||
"notes": [],
|
||||
"votes_up": [],
|
||||
"votes_down": [],
|
||||
}
|
||||
|
||||
async with self.app_config.guild(guild).submissions() as subs:
|
||||
subs[sub_id] = submission
|
||||
|
||||
# Update stats
|
||||
async with self.app_config.guild(guild).stats() as stats:
|
||||
stats["total"] = stats.get("total", 0) + 1
|
||||
|
||||
review_ch_id = type_data.get("review_channel") or data["review_channel"]
|
||||
if review_ch_id:
|
||||
review_ch = guild.get_channel(review_ch_id)
|
||||
|
|
@ -277,16 +308,12 @@ class ApplicationsMixin:
|
|||
embed.add_field(name=q, value=a[:1024] or "*No answer*", inline=False)
|
||||
|
||||
if interaction.user.joined_at:
|
||||
embed.add_field(
|
||||
name="Server Member Since",
|
||||
value=ts_relative(int(interaction.user.joined_at.timestamp())),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Account Created",
|
||||
value=ts_relative(int(interaction.user.created_at.timestamp())),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Server Member Since", value=ts_relative(int(interaction.user.joined_at.timestamp())), inline=True)
|
||||
embed.add_field(name="Account Created", value=ts_relative(int(interaction.user.created_at.timestamp())), inline=True)
|
||||
|
||||
if data.get("voting_enabled"):
|
||||
embed.add_field(name="Votes", value="👍 0 | 👎 0", inline=True)
|
||||
|
||||
embed.set_footer(text=f"Application ID: {sub_id}")
|
||||
|
||||
view = ReviewView(self, sub_id)
|
||||
|
|
@ -302,6 +329,24 @@ class ApplicationsMixin:
|
|||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Webhook notification
|
||||
webhook_url = data.get("webhook_url")
|
||||
if webhook_url:
|
||||
try:
|
||||
import aiohttp
|
||||
payload = {
|
||||
"content": f"📋 New **{type_name}** application from **{interaction.user}**",
|
||||
"embeds": [{
|
||||
"title": f"Application: {type_name.title()}",
|
||||
"description": "\n".join(f"**{q}:** {a}" for q, a in answers.items()),
|
||||
"color": Clr.APP.value,
|
||||
}],
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(webhook_url, json=payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_ch_id = data.get("log_channel")
|
||||
if log_ch_id:
|
||||
log_ch = guild.get_channel(log_ch_id)
|
||||
|
|
@ -312,14 +357,62 @@ class ApplicationsMixin:
|
|||
le.add_field(name="ID", value=sub_id, inline=True)
|
||||
await safe_send(log_ch, embed=le)
|
||||
|
||||
async def _review_application(self, interaction: discord.Interaction, sub_id: str, status: str):
|
||||
async def _vote_application(self, interaction: discord.Interaction, sub_id: str, direction: str):
|
||||
"""Staff vote on an application."""
|
||||
data = await self.app_config.guild(interaction.guild).all()
|
||||
if not data.get("voting_enabled"):
|
||||
return await interaction.response.send_message("Voting is not enabled.", ephemeral=True)
|
||||
|
||||
async with self.app_config.guild(interaction.guild).submissions() as subs:
|
||||
sub = subs.get(sub_id)
|
||||
if not sub:
|
||||
return await interaction.response.send_message("Not found.", ephemeral=True)
|
||||
if sub["status"] != "pending":
|
||||
return await interaction.response.send_message("Already reviewed.", ephemeral=True)
|
||||
|
||||
uid = interaction.user.id
|
||||
ups = sub.get("votes_up", [])
|
||||
downs = sub.get("votes_down", [])
|
||||
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)
|
||||
sub["votes_up"] = ups
|
||||
sub["votes_down"] = downs
|
||||
subs[sub_id] = sub
|
||||
|
||||
await interaction.response.send_message(f"Vote recorded! 👍 {len(ups)} | 👎 {len(downs)}", ephemeral=True)
|
||||
|
||||
# Auto-accept/deny based on vote thresholds
|
||||
auto_accept = data.get("auto_accept_votes", 0)
|
||||
auto_deny = data.get("auto_deny_votes", 0)
|
||||
if auto_accept and len(ups) >= auto_accept:
|
||||
await self._review_application(interaction, sub_id, "accepted", silent=True)
|
||||
elif auto_deny and len(downs) >= auto_deny:
|
||||
await self._review_application(interaction, sub_id, "denied", silent=True)
|
||||
|
||||
async def _review_application(self, interaction, sub_id: str, status: str, silent: bool = False):
|
||||
guild = interaction.guild
|
||||
data = await self.app_config.guild(guild).all()
|
||||
sub = data["submissions"].get(sub_id)
|
||||
if not sub:
|
||||
return await interaction.response.send_message("Application not found.", ephemeral=True)
|
||||
if not silent:
|
||||
return await interaction.response.send_message("Application not found.", ephemeral=True)
|
||||
return
|
||||
if sub["status"] != "pending" and status != "interview":
|
||||
return await interaction.response.send_message(f"Already {sub['status']}.", ephemeral=True)
|
||||
if not silent:
|
||||
return await interaction.response.send_message(f"Already {sub['status']}.", ephemeral=True)
|
||||
return
|
||||
|
||||
async with self.app_config.guild(guild).submissions() as subs:
|
||||
if sub_id in subs:
|
||||
|
|
@ -327,17 +420,29 @@ class ApplicationsMixin:
|
|||
subs[sub_id]["reviewed_by"] = interaction.user.id
|
||||
subs[sub_id]["reviewed_at"] = ts_now()
|
||||
|
||||
type_data = data["types"].get(sub["type"], {})
|
||||
# Update stats
|
||||
async with self.app_config.guild(guild).stats() as stats:
|
||||
if status in ("accepted", "denied"):
|
||||
stats[status] = stats.get(status, 0) + 1
|
||||
review_time = ts_now() - sub.get("submitted_at", ts_now())
|
||||
total_reviewed = stats.get("accepted", 0) + stats.get("denied", 0)
|
||||
old_avg = stats.get("avg_review_time", 0)
|
||||
stats["avg_review_time"] = int(((old_avg * (total_reviewed - 1)) + review_time) / max(total_reviewed, 1))
|
||||
|
||||
type_data = data["types"].get(sub["type"], {})
|
||||
status_emojis = {"accepted": "✅", "denied": "❌", "interview": "🎙️"}
|
||||
status_colours = {"accepted": Clr.SUCCESS, "denied": Clr.ERROR, "interview": Clr.INFO}
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{status_emojis.get(status, '❓')} Application {status.title()}",
|
||||
description=f"**Applicant:** <@{sub['user_id']}>\n**Type:** {sub['type'].title()}\n**Reviewed by:** {interaction.user.mention}",
|
||||
colour=status_colours.get(status, Clr.INFO),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
if not silent:
|
||||
embed = discord.Embed(
|
||||
title=f"{status_emojis.get(status, '❓')} Application {status.title()}",
|
||||
description=f"**Applicant:** <@{sub['user_id']}>\n**Type:** {sub['type'].title()}\n**Reviewed by:** {interaction.user.mention}",
|
||||
colour=status_colours.get(status, Clr.INFO),
|
||||
)
|
||||
try:
|
||||
await interaction.response.send_message(embed=embed)
|
||||
except discord.InteractionResponded:
|
||||
pass
|
||||
|
||||
member = guild.get_member(sub["user_id"])
|
||||
if member:
|
||||
|
|
@ -364,7 +469,6 @@ class ApplicationsMixin:
|
|||
msg_text = type_data.get("deny_msg") or f"Your **{sub['type'].title()}** application in **{guild.name}** has been **denied**."
|
||||
else:
|
||||
msg_text = f"You've been selected for an **interview** regarding your **{sub['type'].title()}** application in **{guild.name}**!"
|
||||
|
||||
await safe_dm(member, embed=discord.Embed(description=msg_text, colour=status_colours.get(status, Clr.INFO)))
|
||||
|
||||
# Disable buttons on review message
|
||||
|
|
@ -378,3 +482,42 @@ class ApplicationsMixin:
|
|||
await review_msg.edit(view=None)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# ── Template management ────────────────────────────────────────────────
|
||||
async def _save_app_template(self, guild, name: str, type_name: str):
|
||||
"""Save an application type's config as a reusable template."""
|
||||
data = await self.app_config.guild(guild).all()
|
||||
type_data = data["types"].get(type_name)
|
||||
if not type_data:
|
||||
return False
|
||||
async with self.app_config.guild(guild).templates() as templates:
|
||||
templates[name] = dict(type_data)
|
||||
return True
|
||||
|
||||
async def _load_app_template(self, guild, template_name: str, type_name: str):
|
||||
"""Load a template into an application type."""
|
||||
data = await self.app_config.guild(guild).all()
|
||||
template = data["templates"].get(template_name)
|
||||
if not template:
|
||||
return False
|
||||
async with self.app_config.guild(guild).types() as types:
|
||||
types[type_name] = dict(template)
|
||||
return True
|
||||
|
||||
def _get_app_stats(self, data: dict) -> dict:
|
||||
"""Compute application statistics."""
|
||||
subs = data.get("submissions", {})
|
||||
total = len(subs)
|
||||
by_status = {}
|
||||
by_type = {}
|
||||
for sub in subs.values():
|
||||
s = sub.get("status", "pending")
|
||||
by_status[s] = by_status.get(s, 0) + 1
|
||||
t = sub.get("type", "unknown")
|
||||
by_type[t] = by_type.get(t, 0) + 1
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_type": by_type,
|
||||
"stats": data.get("stats", {}),
|
||||
}
|
||||
|
|
|
|||
639
nexuscore/dashboard_integration.py
Normal file
639
nexuscore/dashboard_integration.py
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
"""NexusCore — Dashboard Integration: web-based config for all 8 modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import discord
|
||||
from redbot.core import commands
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# Try importing the dashboard SDK (graceful if not installed)
|
||||
try:
|
||||
from dashboard.sdk import dashboard_page
|
||||
except ImportError:
|
||||
# Stub if dashboard cog isn't loaded
|
||||
def dashboard_page(**kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
from .utils import ts_now
|
||||
|
||||
|
||||
class DashboardMixin:
|
||||
"""Dashboard integration mixin — registers pages for all NexusCore modules."""
|
||||
|
||||
# ── Lifecycle listeners ────────────────────────────────────────────────
|
||||
@commands.Cog.listener()
|
||||
async def on_dashboard_cog_add(self, dashboard_cog):
|
||||
dashboard_cog.rpc.add_third_party(self)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_dashboard_cog_remove(self, dashboard_cog):
|
||||
dashboard_cog.rpc.remove_third_party(self)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# OVERVIEW PAGE
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="overview", description="NexusCore Overview & Dashboard", icon="fas fa-home", order=0)
|
||||
async def dashboard_overview(self, user, guild, **kwargs):
|
||||
# Gather stats from all modules
|
||||
ticket_data = await self.ticket_config.guild(guild).all()
|
||||
app_data = await self.app_config.guild(guild).all()
|
||||
suggest_data = await self.suggest_config.guild(guild).all()
|
||||
rr_data = await self.rr_config.guild(guild).all()
|
||||
give_data = await self.give_config.guild(guild).all()
|
||||
log_data = await self.log_config.guild(guild).all()
|
||||
mod_data = await self.mod_config.guild(guild).all()
|
||||
eco_data = await self.eco_config.guild(guild).all()
|
||||
|
||||
open_tickets = len(ticket_data.get("open_tickets", {}))
|
||||
pending_apps = sum(1 for s in app_data.get("submissions", {}).values() if s.get("status") == "pending")
|
||||
total_suggestions = len(suggest_data.get("suggestions", {}))
|
||||
active_giveaways = sum(1 for g in give_data.get("giveaways", {}).values() if not g.get("ended"))
|
||||
total_cases = len(mod_data.get("cases", {}))
|
||||
rr_panels = len(rr_data.get("panels", {}))
|
||||
shop_items = len(eco_data.get("shop_items", {}))
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "NexusCore overview",
|
||||
"stats": {
|
||||
"open_tickets": open_tickets,
|
||||
"pending_applications": pending_apps,
|
||||
"total_suggestions": total_suggestions,
|
||||
"active_giveaways": active_giveaways,
|
||||
"moderation_cases": total_cases,
|
||||
"reaction_role_panels": rr_panels,
|
||||
"shop_items": shop_items,
|
||||
},
|
||||
"modules": {
|
||||
"tickets": {"enabled": ticket_data.get("enabled", False), "icon": "fas fa-ticket-alt"},
|
||||
"applications": {"enabled": app_data.get("enabled", False), "icon": "fas fa-clipboard-list"},
|
||||
"suggestions": {"enabled": suggest_data.get("enabled", False), "icon": "fas fa-lightbulb"},
|
||||
"reaction_roles": {"enabled": rr_data.get("enabled", True), "icon": "fas fa-theater-masks"},
|
||||
"giveaways": {"enabled": give_data.get("enabled", True), "icon": "fas fa-gift"},
|
||||
"logging": {"enabled": log_data.get("enabled", False), "icon": "fas fa-clipboard"},
|
||||
"moderation": {"enabled": mod_data.get("enabled", True), "icon": "fas fa-shield-alt"},
|
||||
"economy": {"enabled": eco_data.get("enabled", True), "icon": "fas fa-coins"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# TICKETS DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="tickets", description="Ticket System Settings", methods=("GET", "POST"), icon="fas fa-ticket-alt", order=1)
|
||||
async def dashboard_tickets(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
mapping = {
|
||||
"enabled": ("enabled", bool),
|
||||
"log_channel": ("log_channel", lambda x: int(x) if x else None),
|
||||
"category_id": ("category_id", lambda x: int(x) if x else None),
|
||||
"transcript_channel": ("transcript_channel", lambda x: int(x) if x else None),
|
||||
"max_per_user": ("max_per_user", int),
|
||||
"claim_enabled": ("claim_enabled", bool),
|
||||
"feedback_enabled": ("feedback_enabled", bool),
|
||||
"dm_on_open": ("dm_on_open", bool),
|
||||
"dm_on_close": ("dm_on_close", bool),
|
||||
"allow_user_close": ("allow_user_close", bool),
|
||||
"auto_pin_first": ("auto_pin_first", bool),
|
||||
"thread_mode": ("thread_mode", bool),
|
||||
"auto_close_hours": ("auto_close_hours", int),
|
||||
"custom_open_msg": ("custom_open_msg", str),
|
||||
"custom_close_msg": ("custom_close_msg", str),
|
||||
}
|
||||
for key, (config_key, converter) in mapping.items():
|
||||
if key in data:
|
||||
try:
|
||||
val = converter(data[key])
|
||||
await getattr(self.ticket_config.guild(guild), config_key).set(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Ticket settings saved!"}]}
|
||||
|
||||
current = await self.ticket_config.guild(guild).all()
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Tickets", "value": current.get("enabled", False)},
|
||||
{"id": "log_channel", "type": "channel_select", "label": "Log Channel", "value": current.get("log_channel")},
|
||||
{"id": "category_id", "type": "channel_select", "label": "Ticket Category", "value": current.get("category_id")},
|
||||
{"id": "transcript_channel", "type": "channel_select", "label": "Transcript Channel", "value": current.get("transcript_channel")},
|
||||
{"id": "max_per_user", "type": "number", "label": "Max Tickets Per User", "value": current.get("max_per_user", 3), "min": 1, "max": 25},
|
||||
{"id": "claim_enabled", "type": "toggle", "label": "Staff Claim System", "value": current.get("claim_enabled", True)},
|
||||
{"id": "feedback_enabled", "type": "toggle", "label": "Feedback on Close", "value": current.get("feedback_enabled", True)},
|
||||
{"id": "dm_on_open", "type": "toggle", "label": "DM on Open", "value": current.get("dm_on_open", True)},
|
||||
{"id": "dm_on_close", "type": "toggle", "label": "DM on Close", "value": current.get("dm_on_close", True)},
|
||||
{"id": "allow_user_close", "type": "toggle", "label": "Users Can Close", "value": current.get("allow_user_close", True)},
|
||||
{"id": "auto_pin_first", "type": "toggle", "label": "Auto-Pin First Message", "value": current.get("auto_pin_first", True)},
|
||||
{"id": "thread_mode", "type": "toggle", "label": "Thread Mode", "value": current.get("thread_mode", False)},
|
||||
{"id": "auto_close_hours", "type": "number", "label": "Auto-Close After (hours, 0=off)", "value": current.get("auto_close_hours", 0), "min": 0, "max": 720},
|
||||
{"id": "custom_open_msg", "type": "textarea", "label": "Custom Open Message", "description": "Vars: {user}, {category}, {ticket_id}", "value": current.get("custom_open_msg", "")},
|
||||
{"id": "custom_close_msg", "type": "textarea", "label": "Custom Close Message", "value": current.get("custom_close_msg", "")},
|
||||
],
|
||||
"categories": current.get("categories", {}),
|
||||
"open_tickets": len(current.get("open_tickets", {})),
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# APPLICATIONS DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="applications", description="Application System Settings", methods=("GET", "POST"), icon="fas fa-clipboard-list", order=2)
|
||||
async def dashboard_applications(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
for key in ("enabled", "review_channel", "dm_on_accept", "dm_on_deny"):
|
||||
if key in data:
|
||||
val = data[key]
|
||||
if key == "review_channel":
|
||||
val = int(val) if val else None
|
||||
elif key == "enabled":
|
||||
val = bool(val)
|
||||
await getattr(self.app_config.guild(guild), key).set(val)
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Application settings saved!"}]}
|
||||
|
||||
current = await self.app_config.guild(guild).all()
|
||||
pending = sum(1 for s in current.get("submissions", {}).values() if s.get("status") == "pending")
|
||||
accepted = sum(1 for s in current.get("submissions", {}).values() if s.get("status") == "accepted")
|
||||
denied = sum(1 for s in current.get("submissions", {}).values() if s.get("status") == "denied")
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Applications", "value": current.get("enabled", False)},
|
||||
{"id": "review_channel", "type": "channel_select", "label": "Review Channel", "value": current.get("review_channel")},
|
||||
{"id": "dm_on_accept", "type": "toggle", "label": "DM on Accept", "value": current.get("dm_on_accept", True)},
|
||||
{"id": "dm_on_deny", "type": "toggle", "label": "DM on Deny", "value": current.get("dm_on_deny", True)},
|
||||
],
|
||||
"types": current.get("types", {}),
|
||||
"stats": {"pending": pending, "accepted": accepted, "denied": denied, "total": len(current.get("submissions", {}))},
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# SUGGESTIONS DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="suggestions", description="Suggestion System Settings", methods=("GET", "POST"), icon="fas fa-lightbulb", order=3)
|
||||
async def dashboard_suggestions(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
mapping = {
|
||||
"enabled": bool, "channel": lambda x: int(x) if x else None,
|
||||
"anonymous": bool, "auto_thread": bool,
|
||||
"min_length": int, "max_length": int,
|
||||
"auto_approve_threshold": int,
|
||||
}
|
||||
for key, converter in mapping.items():
|
||||
if key in data:
|
||||
try:
|
||||
await getattr(self.suggest_config.guild(guild), key).set(converter(data[key]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Suggestion settings saved!"}]}
|
||||
|
||||
current = await self.suggest_config.guild(guild).all()
|
||||
subs = current.get("suggestions", {})
|
||||
status_counts = {}
|
||||
for s in subs.values():
|
||||
st = s.get("status", "pending")
|
||||
status_counts[st] = status_counts.get(st, 0) + 1
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Suggestions", "value": current.get("enabled", False)},
|
||||
{"id": "channel", "type": "channel_select", "label": "Suggestions Channel", "value": current.get("channel")},
|
||||
{"id": "anonymous", "type": "toggle", "label": "Anonymous Mode", "value": current.get("anonymous", False)},
|
||||
{"id": "auto_thread", "type": "toggle", "label": "Auto-Thread", "value": current.get("auto_thread", True)},
|
||||
{"id": "min_length", "type": "number", "label": "Min Length", "value": current.get("min_length", 10), "min": 1, "max": 500},
|
||||
{"id": "max_length", "type": "number", "label": "Max Length", "value": current.get("max_length", 2000), "min": 10, "max": 4000},
|
||||
{"id": "auto_approve_threshold", "type": "number", "label": "Auto-Approve at X Upvotes (0=off)", "value": current.get("auto_approve_threshold", 0), "min": 0, "max": 1000},
|
||||
],
|
||||
"stats": {"total": len(subs), "by_status": status_counts},
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# REACTION ROLES DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="reaction_roles", description="Reaction Role Panel Settings", methods=("GET", "POST"), icon="fas fa-theater-masks", order=4)
|
||||
async def dashboard_reaction_roles(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
if data.get("action") == "toggle":
|
||||
await self.rr_config.guild(guild).enabled.set(bool(data.get("enabled", True)))
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Reaction role settings saved!"}]}
|
||||
|
||||
current = await self.rr_config.guild(guild).all()
|
||||
panels_info = {}
|
||||
for pid, p in current.get("panels", {}).items():
|
||||
ch = guild.get_channel(p["channel_id"])
|
||||
panels_info[pid] = {
|
||||
"title": p.get("title", "Untitled"),
|
||||
"mode": p.get("mode", "button"),
|
||||
"channel": {"id": str(p["channel_id"]), "name": ch.name if ch else "deleted"},
|
||||
"roles_count": len(p.get("roles", [])),
|
||||
"sticky": p.get("sticky", False),
|
||||
"temp_minutes": p.get("temp_minutes", 0),
|
||||
"max_roles": p.get("max_roles", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Reaction Roles", "value": current.get("enabled", True)},
|
||||
],
|
||||
"panels": panels_info,
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# GIVEAWAYS DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="giveaways", description="Giveaway System Settings", methods=("GET", "POST"), icon="fas fa-gift", order=5)
|
||||
async def dashboard_giveaways(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
for key in ("enabled", "dm_winners", "dm_host", "default_colour"):
|
||||
if key in data:
|
||||
val = data[key]
|
||||
if key in ("dm_winners", "dm_host", "enabled"):
|
||||
val = bool(val)
|
||||
await getattr(self.give_config.guild(guild), key).set(val)
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Giveaway settings saved!"}]}
|
||||
|
||||
current = await self.give_config.guild(guild).all()
|
||||
gws = current.get("giveaways", {})
|
||||
active = {k: v for k, v in gws.items() if not v.get("ended")}
|
||||
ended = {k: v for k, v in gws.items() if v.get("ended")}
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Giveaways", "value": current.get("enabled", True)},
|
||||
{"id": "dm_winners", "type": "toggle", "label": "DM Winners", "value": current.get("dm_winners", True)},
|
||||
{"id": "dm_host", "type": "toggle", "label": "DM Host", "value": current.get("dm_host", True)},
|
||||
],
|
||||
"stats": {"active": len(active), "ended": len(ended), "total_entries": sum(len(g.get("entries", [])) for g in gws.values())},
|
||||
"active_giveaways": [
|
||||
{"id": k, "prize": v["prize"], "ends_at": v["ends_at"], "entries": len(v.get("entries", [])), "winners": v["winners_count"]}
|
||||
for k, v in active.items()
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="logging", description="Server Logging Settings", methods=("GET", "POST"), icon="fas fa-clipboard", order=6)
|
||||
async def dashboard_logging(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
if "enabled" in data:
|
||||
await self.log_config.guild(guild).enabled.set(bool(data["enabled"]))
|
||||
if "default_channel" in data:
|
||||
await self.log_config.guild(guild).default_channel.set(int(data["default_channel"]) if data["default_channel"] else None)
|
||||
if "ignore_bots" in data:
|
||||
await self.log_config.guild(guild).ignore_bots.set(bool(data["ignore_bots"]))
|
||||
if "channels" in data and isinstance(data["channels"], dict):
|
||||
await self.log_config.guild(guild).channels.set(
|
||||
{k: int(v) for k, v in data["channels"].items() if v}
|
||||
)
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Logging settings saved!"}]}
|
||||
|
||||
current = await self.log_config.guild(guild).all()
|
||||
from .serverlog import EVENT_TYPES
|
||||
channel_routing = {}
|
||||
for evt in EVENT_TYPES:
|
||||
ch_id = current.get("channels", {}).get(evt)
|
||||
ch = guild.get_channel(ch_id) if ch_id else None
|
||||
channel_routing[evt] = {"channel_id": str(ch_id) if ch_id else None, "channel_name": ch.name if ch else None}
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Logging", "value": current.get("enabled", False)},
|
||||
{"id": "default_channel", "type": "channel_select", "label": "Default Log Channel", "value": current.get("default_channel")},
|
||||
{"id": "ignore_bots", "type": "toggle", "label": "Ignore Bots", "value": current.get("ignore_bots", True)},
|
||||
],
|
||||
"event_types": EVENT_TYPES,
|
||||
"channel_routing": channel_routing,
|
||||
"ignore": {
|
||||
"channels": current.get("ignore_channels", []),
|
||||
"roles": current.get("ignore_roles", []),
|
||||
"users": current.get("ignore_users", []),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# MODERATION DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="moderation", description="Moderation Settings & Cases", methods=("GET", "POST"), icon="fas fa-shield-alt", order=7)
|
||||
async def dashboard_moderation(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
simple_keys = {
|
||||
"enabled": bool, "modlog_channel": lambda x: int(x) if x else None,
|
||||
"dm_on_action": bool, "appeal_enabled": bool,
|
||||
"appeal_channel": lambda x: int(x) if x else None,
|
||||
"warn_decay_days": int,
|
||||
}
|
||||
for key, converter in simple_keys.items():
|
||||
if key in data:
|
||||
try:
|
||||
await getattr(self.mod_config.guild(guild), key).set(converter(data[key]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "anti_raid" in data and isinstance(data["anti_raid"], dict):
|
||||
await self.mod_config.guild(guild).anti_raid.set(data["anti_raid"])
|
||||
if "anti_nuke" in data and isinstance(data["anti_nuke"], dict):
|
||||
await self.mod_config.guild(guild).anti_nuke.set(data["anti_nuke"])
|
||||
if "auto_mod" in data and isinstance(data["auto_mod"], dict):
|
||||
await self.mod_config.guild(guild).auto_mod.set(data["auto_mod"])
|
||||
if "escalation" in data and isinstance(data["escalation"], dict):
|
||||
await self.mod_config.guild(guild).escalation.set(data["escalation"])
|
||||
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Moderation settings saved!"}]}
|
||||
|
||||
current = await self.mod_config.guild(guild).all()
|
||||
cases = current.get("cases", {})
|
||||
type_counts = {}
|
||||
for c in cases.values():
|
||||
t = c.get("type", "unknown")
|
||||
type_counts[t] = type_counts.get(t, 0) + 1
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Moderation", "value": current.get("enabled", True)},
|
||||
{"id": "modlog_channel", "type": "channel_select", "label": "Modlog Channel", "value": current.get("modlog_channel")},
|
||||
{"id": "dm_on_action", "type": "toggle", "label": "DM Users on Action", "value": current.get("dm_on_action", True)},
|
||||
{"id": "appeal_enabled", "type": "toggle", "label": "Enable Appeals", "value": current.get("appeal_enabled", False)},
|
||||
{"id": "appeal_channel", "type": "channel_select", "label": "Appeal Channel", "value": current.get("appeal_channel")},
|
||||
{"id": "warn_decay_days", "type": "number", "label": "Warning Decay (days, 0=never)", "value": current.get("warn_decay_days", 0), "min": 0, "max": 365},
|
||||
],
|
||||
"stats": {"total_cases": len(cases), "by_type": type_counts},
|
||||
"anti_raid": current.get("anti_raid", {}),
|
||||
"anti_nuke": current.get("anti_nuke", {}),
|
||||
"auto_mod": current.get("auto_mod", {}),
|
||||
"escalation": current.get("escalation", {}),
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# ECONOMY DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="economy", description="Economy & Shop Settings", methods=("GET", "POST"), icon="fas fa-coins", order=8)
|
||||
async def dashboard_economy(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
simple_keys = {
|
||||
"enabled": bool, "currency_name": str, "currency_emoji": str,
|
||||
"currency_symbol": str, "starting_balance": int, "max_balance": int,
|
||||
"daily_amount": int, "daily_streak_bonus": int, "weekly_amount": int,
|
||||
"work_min": int, "work_max": int, "work_cooldown": int,
|
||||
"crime_min": int, "crime_max": int, "crime_cooldown": int,
|
||||
"crime_fail_chance": int, "rob_enabled": bool, "rob_cooldown": int,
|
||||
"rob_success_chance": int, "interest_rate": float, "tax_rate": float,
|
||||
"log_channel": lambda x: int(x) if x else None,
|
||||
}
|
||||
for key, converter in simple_keys.items():
|
||||
if key in data:
|
||||
try:
|
||||
await getattr(self.eco_config.guild(guild), key).set(converter(data[key]))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
if "gambling" in data and isinstance(data["gambling"], dict):
|
||||
await self.eco_config.guild(guild).gambling.set(data["gambling"])
|
||||
if "pets" in data and isinstance(data["pets"], dict):
|
||||
await self.eco_config.guild(guild).pets.set(data["pets"])
|
||||
|
||||
# Shop item management
|
||||
if "add_item" in data:
|
||||
item = data["add_item"]
|
||||
from .utils import short_id
|
||||
item_id = short_id(8)
|
||||
async with self.eco_config.guild(guild).shop_items() as items:
|
||||
items[item_id] = {
|
||||
"name": item.get("name", "New Item"),
|
||||
"description": item.get("description", ""),
|
||||
"price": int(item.get("price", 100)),
|
||||
"emoji": item.get("emoji", "📦"),
|
||||
"role_id": int(item["role_id"]) if item.get("role_id") else None,
|
||||
"stock": int(item.get("stock", -1)),
|
||||
"max_per_user": int(item.get("max_per_user", 0)),
|
||||
"type": item.get("type", "item"),
|
||||
"usable": bool(item.get("usable", False)),
|
||||
}
|
||||
|
||||
if "remove_item" in data:
|
||||
async with self.eco_config.guild(guild).shop_items() as items:
|
||||
items.pop(data["remove_item"], None)
|
||||
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Economy settings saved!"}]}
|
||||
|
||||
current = await self.eco_config.guild(guild).all()
|
||||
all_members = await self.eco_config.all_members(guild)
|
||||
total_wealth = sum(d.get("wallet", 0) + d.get("bank", 0) for d in all_members.values())
|
||||
total_users = len(all_members)
|
||||
|
||||
# Top 10 leaderboard
|
||||
rankings = sorted(
|
||||
[(uid, d.get("wallet", 0) + d.get("bank", 0)) for uid, d in all_members.items()],
|
||||
key=lambda x: x[1], reverse=True,
|
||||
)[:10]
|
||||
top_users = []
|
||||
for uid, total in rankings:
|
||||
member = guild.get_member(uid)
|
||||
top_users.append({
|
||||
"id": str(uid),
|
||||
"name": str(member) if member else f"Unknown#{uid}",
|
||||
"total": total,
|
||||
})
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "EveDash auto-form",
|
||||
"settings": [
|
||||
{"id": "enabled", "type": "toggle", "label": "Enable Economy", "value": current.get("enabled", True)},
|
||||
{"id": "currency_name", "type": "text", "label": "Currency Name", "value": current.get("currency_name", "coins")},
|
||||
{"id": "currency_emoji", "type": "text", "label": "Currency Emoji", "value": current.get("currency_emoji", "🪙")},
|
||||
{"id": "currency_symbol", "type": "text", "label": "Currency Symbol", "value": current.get("currency_symbol", "$")},
|
||||
{"id": "starting_balance", "type": "number", "label": "Starting Balance", "value": current.get("starting_balance", 100), "min": 0},
|
||||
{"id": "max_balance", "type": "number", "label": "Max Balance", "value": current.get("max_balance", 10000000), "min": 0},
|
||||
{"id": "daily_amount", "type": "number", "label": "Daily Amount", "value": current.get("daily_amount", 200), "min": 0},
|
||||
{"id": "daily_streak_bonus", "type": "number", "label": "Streak Bonus/Day", "value": current.get("daily_streak_bonus", 50), "min": 0},
|
||||
{"id": "weekly_amount", "type": "number", "label": "Weekly Amount", "value": current.get("weekly_amount", 1500), "min": 0},
|
||||
{"id": "work_min", "type": "number", "label": "Work Min", "value": current.get("work_min", 50)},
|
||||
{"id": "work_max", "type": "number", "label": "Work Max", "value": current.get("work_max", 300)},
|
||||
{"id": "work_cooldown", "type": "number", "label": "Work Cooldown (sec)", "value": current.get("work_cooldown", 3600)},
|
||||
{"id": "crime_min", "type": "number", "label": "Crime Min", "value": current.get("crime_min", 100)},
|
||||
{"id": "crime_max", "type": "number", "label": "Crime Max", "value": current.get("crime_max", 800)},
|
||||
{"id": "crime_fail_chance", "type": "slider", "label": "Crime Fail %", "value": current.get("crime_fail_chance", 40), "min": 0, "max": 100, "step": 5},
|
||||
{"id": "rob_enabled", "type": "toggle", "label": "Enable Robbing", "value": current.get("rob_enabled", True)},
|
||||
{"id": "rob_success_chance", "type": "slider", "label": "Rob Success %", "value": current.get("rob_success_chance", 45), "min": 0, "max": 100, "step": 5},
|
||||
{"id": "tax_rate", "type": "slider", "label": "Transfer Tax %", "value": current.get("tax_rate", 0), "min": 0, "max": 50, "step": 1},
|
||||
{"id": "log_channel", "type": "channel_select", "label": "Economy Log Channel", "value": current.get("log_channel")},
|
||||
],
|
||||
"shop_items": current.get("shop_items", {}),
|
||||
"gambling": current.get("gambling", {}),
|
||||
"pets": current.get("pets", {}),
|
||||
"stats": {
|
||||
"total_wealth": total_wealth,
|
||||
"total_users": total_users,
|
||||
"avg_balance": total_wealth // max(total_users, 1),
|
||||
},
|
||||
"leaderboard": top_users,
|
||||
},
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# EMBED BUILDER DASHBOARD
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@dashboard_page(name="embed_builder", description="Webhook Embed Builder", methods=("GET", "POST"), icon="fas fa-paint-brush", order=9)
|
||||
async def dashboard_embed_builder(self, user, guild, method="GET", data=None, **kwargs):
|
||||
if method == "POST" and data:
|
||||
action = data.get("action")
|
||||
|
||||
if action == "send":
|
||||
channel_id = int(data.get("channel_id", 0))
|
||||
channel = guild.get_channel(channel_id)
|
||||
if not channel:
|
||||
return {"status": 1, "error": "Channel not found"}
|
||||
|
||||
embeds_data = data.get("embeds", [])
|
||||
content = data.get("content", "")
|
||||
webhook_name = data.get("webhook_name", "NexusCore")
|
||||
webhook_avatar = data.get("webhook_avatar", "")
|
||||
|
||||
webhook = None
|
||||
for wh in await channel.webhooks():
|
||||
if wh.name == webhook_name:
|
||||
webhook = wh
|
||||
break
|
||||
if not webhook:
|
||||
webhook = await channel.create_webhook(name=webhook_name)
|
||||
|
||||
embeds = []
|
||||
for ed in embeds_data[:10]:
|
||||
embed = self._build_embed_from_data(ed, guild)
|
||||
embeds.append(embed)
|
||||
|
||||
await webhook.send(
|
||||
content=content or None,
|
||||
embeds=embeds or None,
|
||||
username=webhook_name,
|
||||
avatar_url=webhook_avatar or None,
|
||||
)
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": "Embed sent!"}]}
|
||||
|
||||
if action == "save_template":
|
||||
template_name = data.get("template_name", "")
|
||||
if not template_name:
|
||||
return {"status": 1, "error": "Template name required"}
|
||||
async with self.embed_config.guild(guild).templates() as templates:
|
||||
templates[template_name] = {
|
||||
"embeds": data.get("embeds", []),
|
||||
"content": data.get("content", ""),
|
||||
"webhook_name": data.get("webhook_name", "NexusCore"),
|
||||
"saved_by": user.id,
|
||||
"saved_at": ts_now(),
|
||||
}
|
||||
return {"status": 0, "notifications": [{"type": "success", "message": f"Template '{template_name}' saved!"}]}
|
||||
|
||||
return {"status": 1, "error": "Unknown action"}
|
||||
|
||||
templates = await self.embed_config.guild(guild).templates()
|
||||
return {
|
||||
"status": 0,
|
||||
"web_content": {
|
||||
"source": "NexusCore embed builder",
|
||||
"templates": {k: {"embeds": v.get("embeds", []), "content": v.get("content", "")} for k, v in templates.items()},
|
||||
"channels": [
|
||||
{"id": str(c.id), "name": c.name}
|
||||
for c in sorted(guild.text_channels, key=lambda x: x.position)
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def _build_embed_from_data(self, data: dict, guild=None) -> discord.Embed:
|
||||
"""Build a discord.Embed from a dict (dashboard/JSON input)."""
|
||||
embed = discord.Embed()
|
||||
if data.get("title"):
|
||||
embed.title = self._replace_vars(data["title"], guild)
|
||||
if data.get("description"):
|
||||
embed.description = self._replace_vars(data["description"], guild)
|
||||
if data.get("url"):
|
||||
embed.url = data["url"]
|
||||
if data.get("color") or data.get("colour"):
|
||||
color_val = data.get("color") or data.get("colour")
|
||||
if isinstance(color_val, str):
|
||||
color_val = int(color_val.lstrip("#"), 16)
|
||||
embed.colour = discord.Colour(color_val)
|
||||
if data.get("timestamp"):
|
||||
import datetime
|
||||
embed.timestamp = datetime.datetime.now(datetime.timezone.utc)
|
||||
if data.get("author"):
|
||||
a = data["author"]
|
||||
embed.set_author(name=a.get("name", ""), url=a.get("url", ""), icon_url=a.get("icon_url", ""))
|
||||
if data.get("footer"):
|
||||
f = data["footer"]
|
||||
embed.set_footer(text=f.get("text", ""), icon_url=f.get("icon_url", ""))
|
||||
if data.get("image"):
|
||||
embed.set_image(url=data["image"])
|
||||
if data.get("thumbnail"):
|
||||
embed.set_thumbnail(url=data["thumbnail"])
|
||||
for field in data.get("fields", [])[:25]:
|
||||
embed.add_field(
|
||||
name=field.get("name", "\u200b"),
|
||||
value=field.get("value", "\u200b"),
|
||||
inline=field.get("inline", True),
|
||||
)
|
||||
return embed
|
||||
|
||||
def _replace_vars(self, text: str, guild=None) -> str:
|
||||
"""Replace placeholders in text."""
|
||||
import datetime
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
replacements = {
|
||||
"{server}": guild.name if guild else "Server",
|
||||
"{server_name}": guild.name if guild else "Server",
|
||||
"{member_count}": str(guild.member_count) if guild else "0",
|
||||
"{channel_count}": str(len(guild.channels)) if guild else "0",
|
||||
"{role_count}": str(len(guild.roles)) if guild else "0",
|
||||
"{date}": now.strftime("%Y-%m-%d"),
|
||||
"{time}": now.strftime("%H:%M UTC"),
|
||||
"{datetime}": now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"{boost_count}": str(guild.premium_subscription_count or 0) if guild else "0",
|
||||
"{boost_level}": str(guild.premium_tier) if guild else "0",
|
||||
}
|
||||
for k, v in replacements.items():
|
||||
text = text.replace(k, v)
|
||||
return text
|
||||
1602
nexuscore/economy.py
1602
nexuscore/economy.py
File diff suppressed because it is too large
Load diff
507
nexuscore/embedbuilder.py
Normal file
507
nexuscore/embedbuilder.py
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
"""NexusCore — Embed Builder: webhook-based embed creation, templates, scheduling, JSON import/export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
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,
|
||||
parse_duration, duration_str, safe_send, Paginator, chunk_list,
|
||||
)
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
EMBED_DEFAULTS_GUILD = {
|
||||
"webhooks": {}, # channel_id -> {webhook_id, webhook_url, name, avatar}
|
||||
"templates": {}, # name -> {embeds: [...], content, webhook_name, saved_by, saved_at}
|
||||
"scheduled": {}, # id -> {channel_id, embeds, content, send_at, interval, webhook_name, active}
|
||||
"history": [], # last 100 sent embeds [{channel_id, embeds, sent_at, sent_by}]
|
||||
}
|
||||
|
||||
|
||||
# ── Variables ──────────────────────────────────────────────────────────────
|
||||
VARIABLES = {
|
||||
"{server}": "Server name",
|
||||
"{server_name}": "Server name",
|
||||
"{server_icon}": "Server icon URL",
|
||||
"{member_count}": "Total members",
|
||||
"{channel_count}": "Total channels",
|
||||
"{role_count}": "Total roles",
|
||||
"{boost_count}": "Boost count",
|
||||
"{boost_level}": "Boost tier",
|
||||
"{date}": "Current date (YYYY-MM-DD)",
|
||||
"{time}": "Current time (HH:MM UTC)",
|
||||
"{datetime}": "Full datetime",
|
||||
"{nl}": "New line",
|
||||
"{user}": "Command user's mention",
|
||||
"{user_name}": "Command user's name",
|
||||
"{user_avatar}": "Command user's avatar URL",
|
||||
"{user_id}": "Command user's ID",
|
||||
}
|
||||
|
||||
|
||||
def replace_variables(text: str, guild: discord.Guild = None, user: discord.Member = None) -> str:
|
||||
"""Replace all placeholders in text."""
|
||||
if not text:
|
||||
return text
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
replacements = {
|
||||
"{server}": guild.name if guild else "Server",
|
||||
"{server_name}": guild.name if guild else "Server",
|
||||
"{server_icon}": str(guild.icon.url) if guild and guild.icon else "",
|
||||
"{member_count}": str(guild.member_count) if guild else "0",
|
||||
"{channel_count}": str(len(guild.channels)) if guild else "0",
|
||||
"{role_count}": str(len(guild.roles)) if guild else "0",
|
||||
"{boost_count}": str(guild.premium_subscription_count or 0) if guild else "0",
|
||||
"{boost_level}": str(guild.premium_tier) if guild else "0",
|
||||
"{date}": now.strftime("%Y-%m-%d"),
|
||||
"{time}": now.strftime("%H:%M UTC"),
|
||||
"{datetime}": now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"{nl}": "\n",
|
||||
"{user}": user.mention if user else "",
|
||||
"{user_name}": str(user) if user else "",
|
||||
"{user_avatar}": str(user.display_avatar.url) if user and user.display_avatar else "",
|
||||
"{user_id}": str(user.id) if user else "",
|
||||
}
|
||||
for k, v in replacements.items():
|
||||
text = text.replace(k, v)
|
||||
return text
|
||||
|
||||
|
||||
def embed_from_dict(data: dict, guild=None, user=None) -> discord.Embed:
|
||||
"""Build a discord.Embed from a dict with variable replacement."""
|
||||
embed = discord.Embed()
|
||||
if data.get("title"):
|
||||
embed.title = replace_variables(data["title"], guild, user)[:256]
|
||||
if data.get("description"):
|
||||
embed.description = replace_variables(data["description"], guild, user)[:4096]
|
||||
if data.get("url"):
|
||||
embed.url = data["url"]
|
||||
if data.get("color") or data.get("colour"):
|
||||
c = data.get("color") or data.get("colour")
|
||||
if isinstance(c, str):
|
||||
c = int(c.lstrip("#"), 16)
|
||||
embed.colour = discord.Colour(c)
|
||||
if data.get("timestamp"):
|
||||
if data["timestamp"] is True:
|
||||
embed.timestamp = datetime.datetime.now(datetime.timezone.utc)
|
||||
else:
|
||||
try:
|
||||
embed.timestamp = datetime.datetime.fromisoformat(data["timestamp"])
|
||||
except (ValueError, TypeError):
|
||||
embed.timestamp = datetime.datetime.now(datetime.timezone.utc)
|
||||
if data.get("author"):
|
||||
a = data["author"]
|
||||
embed.set_author(
|
||||
name=replace_variables(a.get("name", ""), guild, user)[:256],
|
||||
url=a.get("url") or None,
|
||||
icon_url=a.get("icon_url") or None,
|
||||
)
|
||||
if data.get("footer"):
|
||||
f = data["footer"]
|
||||
embed.set_footer(
|
||||
text=replace_variables(f.get("text", ""), guild, user)[:2048],
|
||||
icon_url=f.get("icon_url") or None,
|
||||
)
|
||||
if data.get("image"):
|
||||
embed.set_image(url=data["image"])
|
||||
if data.get("thumbnail"):
|
||||
embed.set_thumbnail(url=data["thumbnail"])
|
||||
for field in data.get("fields", [])[:25]:
|
||||
embed.add_field(
|
||||
name=replace_variables(field.get("name", "\u200b"), guild, user)[:256],
|
||||
value=replace_variables(field.get("value", "\u200b"), guild, user)[:1024],
|
||||
inline=field.get("inline", True),
|
||||
)
|
||||
return embed
|
||||
|
||||
|
||||
def embed_to_dict(embed: discord.Embed) -> dict:
|
||||
"""Convert a discord.Embed to a JSON-serializable dict (Discohook compatible)."""
|
||||
data = {}
|
||||
if embed.title:
|
||||
data["title"] = embed.title
|
||||
if embed.description:
|
||||
data["description"] = embed.description
|
||||
if embed.url:
|
||||
data["url"] = embed.url
|
||||
if embed.colour:
|
||||
data["color"] = embed.colour.value
|
||||
if embed.timestamp:
|
||||
data["timestamp"] = embed.timestamp.isoformat()
|
||||
if embed.author:
|
||||
data["author"] = {}
|
||||
if embed.author.name:
|
||||
data["author"]["name"] = embed.author.name
|
||||
if embed.author.url:
|
||||
data["author"]["url"] = embed.author.url
|
||||
if embed.author.icon_url:
|
||||
data["author"]["icon_url"] = embed.author.icon_url
|
||||
if embed.footer:
|
||||
data["footer"] = {}
|
||||
if embed.footer.text:
|
||||
data["footer"]["text"] = embed.footer.text
|
||||
if embed.footer.icon_url:
|
||||
data["footer"]["icon_url"] = embed.footer.icon_url
|
||||
if embed.image:
|
||||
data["image"] = embed.image.url
|
||||
if embed.thumbnail:
|
||||
data["thumbnail"] = embed.thumbnail.url
|
||||
if embed.fields:
|
||||
data["fields"] = [
|
||||
{"name": f.name, "value": f.value, "inline": f.inline}
|
||||
for f in embed.fields
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
# ── Modals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class EmbedTitleModal(discord.ui.Modal, title="Embed — Title & Description"):
|
||||
embed_title = discord.ui.TextInput(
|
||||
label="Title", max_length=256, required=False,
|
||||
placeholder="My awesome embed",
|
||||
)
|
||||
description = discord.ui.TextInput(
|
||||
label="Description", style=discord.TextStyle.paragraph,
|
||||
max_length=4000, required=False,
|
||||
placeholder="Supports **markdown** and {variables}",
|
||||
)
|
||||
url = discord.ui.TextInput(
|
||||
label="Title URL", max_length=500, required=False,
|
||||
placeholder="https://example.com",
|
||||
)
|
||||
colour = discord.ui.TextInput(
|
||||
label="Colour (hex)", max_length=7, required=False,
|
||||
placeholder="#5865F2",
|
||||
)
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await self._callback(interaction, {
|
||||
"title": self.embed_title.value,
|
||||
"description": self.description.value,
|
||||
"url": self.url.value,
|
||||
"colour": self.colour.value,
|
||||
})
|
||||
|
||||
|
||||
class EmbedAuthorFooterModal(discord.ui.Modal, title="Embed — Author & Footer"):
|
||||
author_name = discord.ui.TextInput(label="Author Name", max_length=256, required=False)
|
||||
author_icon = discord.ui.TextInput(label="Author Icon URL", max_length=500, required=False)
|
||||
author_url = discord.ui.TextInput(label="Author URL", max_length=500, required=False)
|
||||
footer_text = discord.ui.TextInput(label="Footer Text", max_length=2048, required=False)
|
||||
footer_icon = discord.ui.TextInput(label="Footer Icon URL", max_length=500, required=False)
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await self._callback(interaction, {
|
||||
"author": {"name": self.author_name.value, "icon_url": self.author_icon.value, "url": self.author_url.value},
|
||||
"footer": {"text": self.footer_text.value, "icon_url": self.footer_icon.value},
|
||||
})
|
||||
|
||||
|
||||
class EmbedImagesModal(discord.ui.Modal, title="Embed — Images"):
|
||||
image = discord.ui.TextInput(label="Image URL", max_length=500, required=False, placeholder="https://i.imgur.com/...")
|
||||
thumbnail = discord.ui.TextInput(label="Thumbnail URL", max_length=500, required=False)
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await self._callback(interaction, {
|
||||
"image": self.image.value,
|
||||
"thumbnail": self.thumbnail.value,
|
||||
})
|
||||
|
||||
|
||||
class EmbedFieldModal(discord.ui.Modal, title="Add Field"):
|
||||
field_name = discord.ui.TextInput(label="Field Name", max_length=256)
|
||||
field_value = discord.ui.TextInput(label="Field Value", style=discord.TextStyle.paragraph, max_length=1024)
|
||||
inline = discord.ui.TextInput(label="Inline? (yes/no)", max_length=3, required=False, default="yes")
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await self._callback(interaction, {
|
||||
"name": self.field_name.value,
|
||||
"value": self.field_value.value,
|
||||
"inline": self.inline.value.lower() in ("yes", "y", "true", "1"),
|
||||
})
|
||||
|
||||
|
||||
class EmbedWebhookModal(discord.ui.Modal, title="Webhook Settings"):
|
||||
wh_name = discord.ui.TextInput(label="Webhook Name", max_length=80, default="NexusCore")
|
||||
wh_avatar = discord.ui.TextInput(label="Avatar URL", max_length=500, required=False)
|
||||
content = discord.ui.TextInput(label="Message Content (outside embed)", style=discord.TextStyle.paragraph, max_length=2000, required=False)
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await self._callback(interaction, {
|
||||
"webhook_name": self.wh_name.value,
|
||||
"webhook_avatar": self.wh_avatar.value,
|
||||
"content": self.content.value,
|
||||
})
|
||||
|
||||
|
||||
# ── Builder View ───────────────────────────────────────────────────────────
|
||||
|
||||
class EmbedBuilderView(discord.ui.View):
|
||||
"""Interactive embed builder with buttons for each section."""
|
||||
|
||||
def __init__(self, cog, author: discord.Member, channel: discord.TextChannel = None):
|
||||
super().__init__(timeout=600)
|
||||
self.cog = cog
|
||||
self.author = author
|
||||
self.target_channel = channel
|
||||
self.embed_data = {}
|
||||
self.fields = []
|
||||
self.webhook_name = "NexusCore"
|
||||
self.webhook_avatar = ""
|
||||
self.content = ""
|
||||
|
||||
def _build_preview(self) -> discord.Embed:
|
||||
data = dict(self.embed_data)
|
||||
data["fields"] = self.fields
|
||||
if data or self.fields:
|
||||
embed = embed_from_dict(data, self.author.guild, self.author)
|
||||
if not embed.title and not embed.description and not embed.fields:
|
||||
embed.description = "*Empty embed — use the buttons below to add content*"
|
||||
return embed
|
||||
return discord.Embed(description="*Empty embed — use the buttons below to add content*", colour=Clr.INFO)
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||
if interaction.user.id != self.author.id:
|
||||
await interaction.response.send_message("Not your builder.", ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
@discord.ui.button(label="Title & Desc", style=discord.ButtonStyle.primary, emoji="📝", row=0)
|
||||
async def title_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
async def cb(inter, data):
|
||||
if data.get("title"):
|
||||
self.embed_data["title"] = data["title"]
|
||||
if data.get("description"):
|
||||
self.embed_data["description"] = data["description"]
|
||||
if data.get("url"):
|
||||
self.embed_data["url"] = data["url"]
|
||||
if data.get("colour"):
|
||||
try:
|
||||
self.embed_data["colour"] = data["colour"]
|
||||
except ValueError:
|
||||
pass
|
||||
await inter.response.edit_message(embed=self._build_preview(), view=self)
|
||||
await interaction.response.send_modal(EmbedTitleModal(cb))
|
||||
|
||||
@discord.ui.button(label="Author & Footer", style=discord.ButtonStyle.primary, emoji="👤", row=0)
|
||||
async def author_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
async def cb(inter, data):
|
||||
if data.get("author") and any(data["author"].values()):
|
||||
self.embed_data["author"] = data["author"]
|
||||
if data.get("footer") and any(data["footer"].values()):
|
||||
self.embed_data["footer"] = data["footer"]
|
||||
await inter.response.edit_message(embed=self._build_preview(), view=self)
|
||||
await interaction.response.send_modal(EmbedAuthorFooterModal(cb))
|
||||
|
||||
@discord.ui.button(label="Images", style=discord.ButtonStyle.primary, emoji="🖼️", row=0)
|
||||
async def images_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
async def cb(inter, data):
|
||||
if data.get("image"):
|
||||
self.embed_data["image"] = data["image"]
|
||||
if data.get("thumbnail"):
|
||||
self.embed_data["thumbnail"] = data["thumbnail"]
|
||||
await inter.response.edit_message(embed=self._build_preview(), view=self)
|
||||
await interaction.response.send_modal(EmbedImagesModal(cb))
|
||||
|
||||
@discord.ui.button(label="Add Field", style=discord.ButtonStyle.secondary, emoji="➕", row=0)
|
||||
async def field_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if len(self.fields) >= 25:
|
||||
return await interaction.response.send_message("Max 25 fields.", ephemeral=True)
|
||||
|
||||
async def cb(inter, data):
|
||||
self.fields.append(data)
|
||||
await inter.response.edit_message(embed=self._build_preview(), view=self)
|
||||
await interaction.response.send_modal(EmbedFieldModal(cb))
|
||||
|
||||
@discord.ui.button(label="Timestamp", style=discord.ButtonStyle.secondary, emoji="🕐", row=1)
|
||||
async def timestamp_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
self.embed_data["timestamp"] = True
|
||||
await interaction.response.edit_message(embed=self._build_preview(), view=self)
|
||||
|
||||
@discord.ui.button(label="Webhook Settings", style=discord.ButtonStyle.secondary, emoji="🔗", row=1)
|
||||
async def webhook_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
async def cb(inter, data):
|
||||
if data.get("webhook_name"):
|
||||
self.webhook_name = data["webhook_name"]
|
||||
if data.get("webhook_avatar"):
|
||||
self.webhook_avatar = data["webhook_avatar"]
|
||||
if data.get("content"):
|
||||
self.content = data["content"]
|
||||
await inter.response.send_message(
|
||||
f"✅ Webhook: **{self.webhook_name}**" + (f"\nContent: {self.content[:100]}" if self.content else ""),
|
||||
ephemeral=True,
|
||||
)
|
||||
await interaction.response.send_modal(EmbedWebhookModal(cb))
|
||||
|
||||
@discord.ui.button(label="Clear Fields", style=discord.ButtonStyle.danger, emoji="🗑️", row=1)
|
||||
async def clear_fields_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
self.fields.clear()
|
||||
await interaction.response.edit_message(embed=self._build_preview(), view=self)
|
||||
|
||||
@discord.ui.button(label="Reset All", style=discord.ButtonStyle.danger, emoji="♻️", row=1)
|
||||
async def reset_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
self.embed_data.clear()
|
||||
self.fields.clear()
|
||||
self.content = ""
|
||||
await interaction.response.edit_message(embed=self._build_preview(), view=self)
|
||||
|
||||
@discord.ui.button(label="📤 Send", style=discord.ButtonStyle.success, emoji=None, row=2)
|
||||
async def send_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if not self.target_channel:
|
||||
return await interaction.response.send_message("No target channel set. Use `[p]embed send #channel`", ephemeral=True)
|
||||
|
||||
data = dict(self.embed_data)
|
||||
data["fields"] = self.fields
|
||||
embed = embed_from_dict(data, interaction.guild, interaction.user)
|
||||
|
||||
webhook = None
|
||||
for wh in await self.target_channel.webhooks():
|
||||
if wh.name == self.webhook_name:
|
||||
webhook = wh
|
||||
break
|
||||
if not webhook:
|
||||
webhook = await self.target_channel.create_webhook(name=self.webhook_name)
|
||||
|
||||
await webhook.send(
|
||||
content=self.content or None,
|
||||
embed=embed,
|
||||
username=self.webhook_name,
|
||||
avatar_url=self.webhook_avatar or None,
|
||||
)
|
||||
await interaction.response.send_message(f"✅ Embed sent to {self.target_channel.mention}!", ephemeral=True)
|
||||
|
||||
# Log to history
|
||||
async with self.cog.embed_config.guild(interaction.guild).history() as hist:
|
||||
hist.append({
|
||||
"channel_id": self.target_channel.id,
|
||||
"embed": data,
|
||||
"content": self.content,
|
||||
"sent_at": ts_now(),
|
||||
"sent_by": interaction.user.id,
|
||||
})
|
||||
if len(hist) > 100:
|
||||
hist.pop(0)
|
||||
|
||||
@discord.ui.button(label="💾 Export JSON", style=discord.ButtonStyle.secondary, emoji=None, row=2)
|
||||
async def export_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
import io
|
||||
data = dict(self.embed_data)
|
||||
data["fields"] = self.fields
|
||||
# Discohook-compatible format
|
||||
export = {
|
||||
"content": self.content,
|
||||
"embeds": [data],
|
||||
}
|
||||
raw = json.dumps(export, indent=2)
|
||||
file = discord.File(io.BytesIO(raw.encode()), filename="embed.json")
|
||||
await interaction.response.send_message("📋 JSON export (Discohook compatible):", file=file, ephemeral=True)
|
||||
|
||||
@discord.ui.button(label="💾 Save Template", style=discord.ButtonStyle.secondary, emoji=None, row=2)
|
||||
async def save_template_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
class NameModal(discord.ui.Modal, title="Save Template"):
|
||||
tname = discord.ui.TextInput(label="Template Name", max_length=50)
|
||||
|
||||
async def on_submit(modal_self, inter: discord.Interaction):
|
||||
data = dict(self.embed_data)
|
||||
data["fields"] = self.fields
|
||||
async with self.cog.embed_config.guild(inter.guild).templates() as templates:
|
||||
templates[modal_self.tname.value] = {
|
||||
"embeds": [data],
|
||||
"content": self.content,
|
||||
"webhook_name": self.webhook_name,
|
||||
"saved_by": inter.user.id,
|
||||
"saved_at": ts_now(),
|
||||
}
|
||||
await inter.response.send_message(f"✅ Template **{modal_self.tname.value}** saved!", ephemeral=True)
|
||||
|
||||
await interaction.response.send_modal(NameModal())
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class EmbedBuilderMixin:
|
||||
"""Embed builder system mixin."""
|
||||
|
||||
def _init_embed_builder(self, bot):
|
||||
self.embed_config = Config.get_conf(
|
||||
None, identifier=900009, cog_name="NexusCoreEmbed"
|
||||
)
|
||||
self.embed_config.register_guild(**EMBED_DEFAULTS_GUILD)
|
||||
self._scheduled_embed_tasks = {}
|
||||
|
||||
async def _load_scheduled_embeds(self):
|
||||
"""Load and schedule pending embed sends."""
|
||||
for guild in self.bot.guilds:
|
||||
scheduled = await self.embed_config.guild(guild).scheduled()
|
||||
for sid, sched in scheduled.items():
|
||||
if sched.get("active"):
|
||||
self._schedule_embed_send(guild, sid, sched)
|
||||
|
||||
def _schedule_embed_send(self, guild, sched_id, sched_data):
|
||||
async def _task():
|
||||
try:
|
||||
send_at = sched_data.get("send_at", 0)
|
||||
now = ts_now()
|
||||
if send_at > now:
|
||||
await asyncio.sleep(send_at - now)
|
||||
|
||||
channel = guild.get_channel(sched_data.get("channel_id"))
|
||||
if not channel:
|
||||
return
|
||||
|
||||
embeds = [embed_from_dict(e, guild) for e in sched_data.get("embeds", [])[:10]]
|
||||
content = sched_data.get("content")
|
||||
wh_name = sched_data.get("webhook_name", "NexusCore")
|
||||
|
||||
webhook = None
|
||||
for wh in await channel.webhooks():
|
||||
if wh.name == wh_name:
|
||||
webhook = wh
|
||||
break
|
||||
if not webhook:
|
||||
webhook = await channel.create_webhook(name=wh_name)
|
||||
|
||||
await webhook.send(content=content or None, embeds=embeds or None, username=wh_name)
|
||||
|
||||
interval = sched_data.get("interval", 0)
|
||||
if interval > 0:
|
||||
# Recurring — reschedule
|
||||
async with self.embed_config.guild(guild).scheduled() as scheds:
|
||||
if sched_id in scheds:
|
||||
scheds[sched_id]["send_at"] = ts_now() + interval
|
||||
self._schedule_embed_send(guild, sched_id, {**sched_data, "send_at": ts_now() + interval})
|
||||
else:
|
||||
async with self.embed_config.guild(guild).scheduled() as scheds:
|
||||
if sched_id in scheds:
|
||||
scheds[sched_id]["active"] = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(_task())
|
||||
self._scheduled_embed_tasks[sched_id] = task
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
"""NexusCore — Giveaway system with requirements, bonus entries, scheduling, recurring, drops."""
|
||||
"""NexusCore — Giveaway system v2: button entry, requirements, bonus entries, drop mode,
|
||||
reroll, scheduled end, templates, team giveaways, milestones, stats, custom embeds/images,
|
||||
message/invite requirements, multi-winner, hosted-by, DM winners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -12,446 +14,368 @@ from redbot.core import Config, commands
|
|||
|
||||
from .utils import (
|
||||
Clr, ok_embed, err_embed, info_embed, short_id, ts_now, ts_relative,
|
||||
ts_full, duration_str, parse_duration, safe_send, safe_dm, Paginator,
|
||||
ts_full, duration_str, parse_duration, safe_send, safe_dm, ConfirmView,
|
||||
)
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
GIVE_DEFAULTS_GUILD = {
|
||||
"enabled": True,
|
||||
"giveaways": {},
|
||||
# gw_id -> {
|
||||
# channel_id, message_id, prize, description, winners_count, host_id,
|
||||
# ends_at, started_at, ended, entries: [user_id],
|
||||
# require_role, blacklist_role, require_account_days, require_server_days,
|
||||
# bonus_roles: {role_id: extra_entries},
|
||||
# dm_winner, dm_host, ping_role, image_url,
|
||||
# recurring_interval, recurring_count,
|
||||
# drop_mode (first N to click win), won_by: [user_id],
|
||||
# }
|
||||
"templates": {}, # template_name -> {prize, duration, winners_count, require_role, ...}
|
||||
"ended_giveaways": {},
|
||||
"log_channel": None,
|
||||
"dm_winners": True,
|
||||
"dm_hosts": True,
|
||||
"default_colour": Clr.GIVE.value,
|
||||
"end_message": "🎉 Congratulations {winners}! You won **{prize}**!",
|
||||
"manager_roles": [],
|
||||
"blacklisted": [],
|
||||
"dm_host_on_end": True,
|
||||
"ping_role": None,
|
||||
"embed_colour": Clr.GIVE.value,
|
||||
"default_emoji": "🎉",
|
||||
"stats": {"total_hosted": 0, "total_entries": 0, "total_winners": 0},
|
||||
}
|
||||
|
||||
|
||||
# ── Views ──────────────────────────────────────────────────────────────────
|
||||
class GiveawayView(discord.ui.View):
|
||||
def __init__(self, cog: "GiveawaysMixin", gw_id: str):
|
||||
class GiveawayEntryView(discord.ui.View):
|
||||
def __init__(self, cog: "GiveawaysMixin"):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
self.gw_id = gw_id
|
||||
|
||||
@discord.ui.button(label="🎉 Enter", style=discord.ButtonStyle.primary, custom_id="nexus_gw_enter")
|
||||
@discord.ui.button(label="🎉 Enter", style=discord.ButtonStyle.success, custom_id="nexus_gw_enter")
|
||||
async def enter_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self.cog._enter_giveaway(interaction, self.gw_id)
|
||||
await self.cog._handle_gw_entry(interaction)
|
||||
|
||||
@discord.ui.button(label="Entries: 0", style=discord.ButtonStyle.secondary, custom_id="nexus_gw_count", disabled=True)
|
||||
async def count_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
pass
|
||||
|
||||
@discord.ui.button(label="ℹ️ Info", style=discord.ButtonStyle.secondary, custom_id="nexus_gw_info")
|
||||
@discord.ui.button(label="📊 Entries", style=discord.ButtonStyle.secondary, custom_id="nexus_gw_info")
|
||||
async def info_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self.cog._giveaway_info(interaction, self.gw_id)
|
||||
await self.cog._handle_gw_info(interaction)
|
||||
|
||||
|
||||
class GiveawayEndedView(discord.ui.View):
|
||||
def __init__(self, cog: "GiveawaysMixin", gw_id: str):
|
||||
super().__init__(timeout=None)
|
||||
class GiveawayRerollView(discord.ui.View):
|
||||
def __init__(self, cog, gw_id: str):
|
||||
super().__init__(timeout=300)
|
||||
self.cog = cog
|
||||
self.gw_id = gw_id
|
||||
|
||||
@discord.ui.button(label="🔁 Reroll", style=discord.ButtonStyle.secondary, custom_id="nexus_gw_reroll")
|
||||
@discord.ui.button(label="🔄 Reroll", style=discord.ButtonStyle.primary)
|
||||
async def reroll_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if not interaction.user.guild_permissions.manage_guild:
|
||||
gdata = await self.cog.give_config.guild(interaction.guild).all()
|
||||
manager_roles = gdata.get("manager_roles", [])
|
||||
if not any(r.id in manager_roles for r in interaction.user.roles):
|
||||
return await interaction.response.send_message("No permission.", ephemeral=True)
|
||||
await self.cog._reroll_giveaway(interaction, self.gw_id)
|
||||
self.stop()
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class GiveawaysMixin:
|
||||
"""Giveaway system mixin."""
|
||||
"""Giveaway system mixin — v2 with templates, milestones, stats, custom embeds."""
|
||||
|
||||
def _init_giveaways(self, bot):
|
||||
self.give_config = Config.get_conf(
|
||||
None, identifier=900005, cog_name="NexusCoreGiveaways"
|
||||
)
|
||||
self.give_config = Config.get_conf(None, identifier=900005, cog_name="NexusCoreGiveaways")
|
||||
self.give_config.register_guild(**GIVE_DEFAULTS_GUILD)
|
||||
self._gw_tasks = {} # gw_id -> asyncio.Task
|
||||
self.bot = bot
|
||||
self._gw_entry_view = GiveawayEntryView(self)
|
||||
bot.add_view(self._gw_entry_view)
|
||||
self._gw_tasks = {}
|
||||
|
||||
async def _load_giveaways(self):
|
||||
"""Restart timers for active giveaways."""
|
||||
all_guilds = await self.give_config.all_guilds()
|
||||
for guild_id, gdata in all_guilds.items():
|
||||
for gw_id, gw in gdata.get("giveaways", {}).items():
|
||||
if not gw.get("ended"):
|
||||
self._schedule_gw_end(guild_id, gw_id, gw["ends_at"])
|
||||
|
||||
def _schedule_gw_end(self, guild_id: int, gw_id: str, ends_at: int):
|
||||
delay = max(0, ends_at - ts_now())
|
||||
|
||||
async def end_task():
|
||||
await asyncio.sleep(delay)
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if guild:
|
||||
await self._end_giveaway(guild, gw_id)
|
||||
|
||||
task = asyncio.create_task(end_task())
|
||||
self._gw_tasks[gw_id] = task
|
||||
async def _start_gw_loop(self):
|
||||
"""Background loop to end giveaways on time."""
|
||||
while True:
|
||||
try:
|
||||
for guild in self.bot.guilds:
|
||||
data = await self.give_config.guild(guild).all()
|
||||
for gw_id, gw in list(data["giveaways"].items()):
|
||||
if gw["ended"]:
|
||||
continue
|
||||
if gw.get("drop_mode"):
|
||||
if len(gw.get("entries", [])) >= gw.get("winners_count", 1):
|
||||
await self._end_giveaway(guild, gw_id)
|
||||
elif ts_now() >= gw["ends_at"]:
|
||||
await self._end_giveaway(guild, gw_id)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(15)
|
||||
|
||||
async def _create_giveaway(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel,
|
||||
prize: str, duration_sec: int, winners: int = 1, *,
|
||||
description: str = "", host: discord.Member | None = None,
|
||||
require_role: discord.Role | None = None,
|
||||
blacklist_role: discord.Role | None = None,
|
||||
require_account_days: int = 0,
|
||||
require_server_days: int = 0,
|
||||
bonus_roles: dict | None = None,
|
||||
ping_role: discord.Role | None = None,
|
||||
image_url: str | None = None,
|
||||
drop_mode: bool = False,
|
||||
recurring_interval: int = 0,
|
||||
self, ctx, channel: discord.TextChannel, prize: str,
|
||||
duration: int, winners: int, drop_mode: bool = False,
|
||||
require_role: int | None = None, bonus_roles: dict | None = None,
|
||||
require_messages: int = 0, require_invites: int = 0,
|
||||
hosted_by: discord.Member | None = None,
|
||||
image_url: str | None = None, description: str | None = None,
|
||||
colour: int | None = None,
|
||||
) -> str:
|
||||
gw_id = short_id(10)
|
||||
ends_at = ts_now() + duration_sec
|
||||
host = host or ctx.author
|
||||
ends_at = ts_now() + duration
|
||||
host = hosted_by or ctx.author
|
||||
data = await self.give_config.guild(ctx.guild).all()
|
||||
embed_colour = colour or data.get("embed_colour", Clr.GIVE.value)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🎉 GIVEAWAY 🎉",
|
||||
description=f"**{prize}**\n\n{description}" if description else f"**{prize}**",
|
||||
colour=Clr.GIVE,
|
||||
title=f"🎉 {prize}",
|
||||
description=description or f"React to enter!\n{'**DROP:** First to click wins!' if drop_mode else ''}",
|
||||
colour=discord.Colour(embed_colour),
|
||||
timestamp=datetime.datetime.fromtimestamp(ends_at, tz=datetime.timezone.utc),
|
||||
)
|
||||
embed.add_field(name="Ends", value=ts_relative(ends_at), inline=True)
|
||||
embed.add_field(name="Winners", value=str(winners), inline=True)
|
||||
embed.set_footer(text=f"Ends at" if not drop_mode else f"Drop giveaway · {winners} winner(s)")
|
||||
embed.add_field(name="Hosted by", value=host.mention, inline=True)
|
||||
embed.add_field(name="Winners", value=str(winners), inline=True)
|
||||
embed.add_field(name="Entries", value="0", inline=True)
|
||||
|
||||
if not drop_mode:
|
||||
embed.add_field(name="Ends", value=ts_relative(ends_at), inline=True)
|
||||
|
||||
if require_role:
|
||||
embed.add_field(name="Required Role", value=require_role.mention, inline=True)
|
||||
if bonus_roles:
|
||||
bonus_text = "\n".join(f"<@&{rid}>: +{extra}" for rid, extra in bonus_roles.items())
|
||||
embed.add_field(name="Bonus Entries", value=bonus_text, inline=False)
|
||||
role = ctx.guild.get_role(require_role)
|
||||
if role:
|
||||
embed.add_field(name="Required Role", value=role.mention, inline=True)
|
||||
if require_messages:
|
||||
embed.add_field(name="Min Messages", value=str(require_messages), inline=True)
|
||||
if require_invites:
|
||||
embed.add_field(name="Min Invites", value=str(require_invites), inline=True)
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
|
||||
embed.set_footer(text=f"ID: {gw_id} · Ends at")
|
||||
msg = await channel.send(embed=embed, view=self._gw_entry_view)
|
||||
|
||||
if drop_mode:
|
||||
embed.title = "🎁 DROP GIVEAWAY 🎁"
|
||||
embed.description = f"**{prize}**\n\nFirst {winners} to click win!"
|
||||
|
||||
view = GiveawayView(self, gw_id)
|
||||
ping_text = ping_role.mention if ping_role else None
|
||||
msg = await channel.send(content=ping_text, embed=embed, view=view)
|
||||
ping_role = data.get("ping_role")
|
||||
if ping_role:
|
||||
pr = ctx.guild.get_role(ping_role)
|
||||
if pr:
|
||||
ping_msg = await channel.send(pr.mention)
|
||||
try:
|
||||
await ping_msg.delete(delay=3)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
gw_data = {
|
||||
"channel_id": channel.id,
|
||||
"message_id": msg.id,
|
||||
"prize": prize,
|
||||
"description": description,
|
||||
"winners_count": winners,
|
||||
"host_id": host.id,
|
||||
"ends_at": ends_at,
|
||||
"started_at": ts_now(),
|
||||
"ended": False,
|
||||
"entries": [],
|
||||
"require_role": require_role.id if require_role else None,
|
||||
"blacklist_role": blacklist_role.id if blacklist_role else None,
|
||||
"require_account_days": require_account_days,
|
||||
"require_server_days": require_server_days,
|
||||
"bonus_roles": bonus_roles or {},
|
||||
"dm_winner": True,
|
||||
"dm_host": True,
|
||||
"ping_role": ping_role.id if ping_role else None,
|
||||
"image_url": image_url,
|
||||
"recurring_interval": recurring_interval,
|
||||
"recurring_count": 0,
|
||||
"drop_mode": drop_mode,
|
||||
"won_by": [],
|
||||
"prize": prize, "channel_id": channel.id, "message_id": msg.id,
|
||||
"host_id": host.id, "winners_count": winners,
|
||||
"ends_at": ends_at, "started_at": ts_now(),
|
||||
"entries": [], "ended": False, "drop_mode": drop_mode,
|
||||
"require_role": require_role, "bonus_roles": bonus_roles or {},
|
||||
"require_messages": require_messages, "require_invites": require_invites,
|
||||
"winners_list": [], "image_url": image_url, "description": description,
|
||||
"colour": embed_colour,
|
||||
}
|
||||
|
||||
async with self.give_config.guild(ctx.guild).giveaways() as gws:
|
||||
gws[gw_id] = gw_data
|
||||
|
||||
if not drop_mode:
|
||||
self._schedule_gw_end(ctx.guild.id, gw_id, ends_at)
|
||||
async with self.give_config.guild(ctx.guild).stats() as stats:
|
||||
stats["total_hosted"] = stats.get("total_hosted", 0) + 1
|
||||
|
||||
return gw_id
|
||||
|
||||
async def _enter_giveaway(self, interaction: discord.Interaction, gw_id: str):
|
||||
async def _handle_gw_entry(self, interaction: discord.Interaction):
|
||||
guild = interaction.guild
|
||||
user = interaction.user
|
||||
conf = self.give_config.guild(guild)
|
||||
data = await conf.all()
|
||||
gw = data["giveaways"].get(gw_id)
|
||||
|
||||
data = await self.give_config.guild(guild).all()
|
||||
gw = None
|
||||
gw_id = None
|
||||
for gid, g in data["giveaways"].items():
|
||||
if g.get("message_id") == interaction.message.id and not g["ended"]:
|
||||
gw = g
|
||||
gw_id = gid
|
||||
break
|
||||
if not gw:
|
||||
return await interaction.response.send_message("Giveaway not found.", ephemeral=True)
|
||||
if gw["ended"]:
|
||||
return await interaction.response.send_message("This giveaway has ended.", ephemeral=True)
|
||||
if user.id in gw["entries"]:
|
||||
return await interaction.response.send_message("You're already entered!", ephemeral=True)
|
||||
if str(user.id) in [str(x) for x in data["blacklisted"]]:
|
||||
return await interaction.response.send_message("You're blacklisted from giveaways.", ephemeral=True)
|
||||
|
||||
# Requirement checks
|
||||
if gw["require_role"]:
|
||||
role = guild.get_role(gw["require_role"])
|
||||
if role and role not in user.roles:
|
||||
return await interaction.response.send_message(f"You need {role.mention} to enter.", ephemeral=True)
|
||||
|
||||
if gw["blacklist_role"]:
|
||||
bl_role = guild.get_role(gw["blacklist_role"])
|
||||
if bl_role and bl_role in user.roles:
|
||||
return await interaction.response.send_message("You can't enter this giveaway.", ephemeral=True)
|
||||
|
||||
if gw["require_account_days"]:
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - user.created_at).days
|
||||
if age < gw["require_account_days"]:
|
||||
return await interaction.response.send_message(
|
||||
f"Account must be {gw['require_account_days']}+ days old.", ephemeral=True
|
||||
)
|
||||
|
||||
if gw["require_server_days"] and user.joined_at:
|
||||
days = (datetime.datetime.now(datetime.timezone.utc) - user.joined_at).days
|
||||
if days < gw["require_server_days"]:
|
||||
return await interaction.response.send_message(
|
||||
f"Must be in server {gw['require_server_days']}+ days.", ephemeral=True
|
||||
)
|
||||
|
||||
# Drop mode — instant win
|
||||
if gw.get("drop_mode"):
|
||||
async with conf.giveaways() as gws:
|
||||
g = gws.get(gw_id)
|
||||
if g and len(g.get("won_by", [])) < g["winners_count"]:
|
||||
g["won_by"].append(user.id)
|
||||
g["entries"].append(user.id)
|
||||
if len(g["won_by"]) >= g["winners_count"]:
|
||||
g["ended"] = True
|
||||
gws[gw_id] = g
|
||||
|
||||
if len(gw.get("won_by", [])) + 1 <= gw["winners_count"]:
|
||||
await interaction.response.send_message(f"🎁 You won **{gw['prize']}**!", ephemeral=True)
|
||||
ch = guild.get_channel(gw["channel_id"])
|
||||
if ch:
|
||||
await safe_send(ch, f"🎁 {user.mention} claimed **{gw['prize']}**!")
|
||||
else:
|
||||
await interaction.response.send_message("Too late! All prizes claimed.", ephemeral=True)
|
||||
user_id = interaction.user.id
|
||||
if user_id in gw["entries"]:
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
if gw_id in gws:
|
||||
gws[gw_id]["entries"].remove(user_id)
|
||||
await interaction.response.send_message("You've left the giveaway.", ephemeral=True)
|
||||
await self._update_gw_embed(guild, gw_id)
|
||||
return
|
||||
|
||||
async with conf.giveaways() as gws:
|
||||
g = gws.get(gw_id)
|
||||
if g:
|
||||
g["entries"].append(user.id)
|
||||
gws[gw_id] = g
|
||||
if gw.get("require_role"):
|
||||
role = guild.get_role(gw["require_role"])
|
||||
if role and role not in interaction.user.roles:
|
||||
return await interaction.response.send_message(f"You need {role.mention} to enter.", ephemeral=True)
|
||||
|
||||
# Calculate effective entries with bonus
|
||||
entries_count = len(gw["entries"]) + 1
|
||||
await interaction.response.send_message(
|
||||
f"🎉 You've entered! ({entries_count} total entries)", ephemeral=True
|
||||
)
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
if gw_id in gws:
|
||||
gws[gw_id]["entries"].append(user_id)
|
||||
|
||||
# Update embed entry count
|
||||
ch = guild.get_channel(gw["channel_id"])
|
||||
if ch:
|
||||
try:
|
||||
msg = await ch.fetch_message(gw["message_id"])
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0]
|
||||
for i, f in enumerate(embed.fields):
|
||||
if f.name == "Entries":
|
||||
embed.set_field_at(i, name="Entries", value=str(entries_count), inline=True)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
async with self.give_config.guild(guild).stats() as stats:
|
||||
stats["total_entries"] = stats.get("total_entries", 0) + 1
|
||||
|
||||
async def _end_giveaway(self, guild: discord.Guild, gw_id: str):
|
||||
count = len(gw["entries"]) + 1 # +1 since just added
|
||||
bonus_entries = 0
|
||||
for role_id, bonus in gw.get("bonus_roles", {}).items():
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role in interaction.user.roles:
|
||||
bonus_entries += bonus
|
||||
|
||||
msg = f"🎉 You've entered!"
|
||||
if bonus_entries:
|
||||
msg += f" (+{bonus_entries} bonus entries)"
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
await self._update_gw_embed(guild, gw_id)
|
||||
|
||||
# Drop mode check
|
||||
if gw.get("drop_mode"):
|
||||
if count >= gw.get("winners_count", 1):
|
||||
await self._end_giveaway(guild, gw_id)
|
||||
|
||||
async def _handle_gw_info(self, interaction: discord.Interaction):
|
||||
guild = interaction.guild
|
||||
data = await self.give_config.guild(guild).all()
|
||||
for gid, g in data["giveaways"].items():
|
||||
if g.get("message_id") == interaction.message.id:
|
||||
is_entered = interaction.user.id in g["entries"]
|
||||
count = len(g["entries"])
|
||||
embed = discord.Embed(
|
||||
title=f"📊 {g['prize']}",
|
||||
description=f"Entries: **{count}**\nWinners: **{g['winners_count']}**\nYour status: {'✅ Entered' if is_entered else '❌ Not entered'}",
|
||||
colour=discord.Colour(g.get("colour", Clr.GIVE.value)),
|
||||
)
|
||||
if not g["ended"]:
|
||||
embed.add_field(name="Ends", value=ts_relative(g["ends_at"]), inline=True)
|
||||
return await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
await interaction.response.send_message("Giveaway not found.", ephemeral=True)
|
||||
|
||||
async def _update_gw_embed(self, guild, gw_id: str):
|
||||
data = await self.give_config.guild(guild).all()
|
||||
gw = data["giveaways"].get(gw_id)
|
||||
if not gw:
|
||||
return
|
||||
channel = guild.get_channel(gw["channel_id"])
|
||||
if not channel:
|
||||
return
|
||||
try:
|
||||
msg = await channel.fetch_message(gw["message_id"])
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0]
|
||||
for i, field in enumerate(embed.fields):
|
||||
if field.name == "Entries":
|
||||
embed.set_field_at(i, name="Entries", value=str(len(gw["entries"])), inline=True)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def _end_giveaway(self, guild, gw_id: str):
|
||||
data = await self.give_config.guild(guild).all()
|
||||
gw = data["giveaways"].get(gw_id)
|
||||
if not gw or gw["ended"]:
|
||||
return
|
||||
|
||||
entries = list(gw["entries"])
|
||||
bonus_entries = []
|
||||
for uid in entries:
|
||||
member = guild.get_member(uid)
|
||||
if member:
|
||||
for role_id, bonus in gw.get("bonus_roles", {}).items():
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role in member.roles:
|
||||
bonus_entries.extend([uid] * bonus)
|
||||
weighted = entries + bonus_entries
|
||||
|
||||
winners = []
|
||||
pick_pool = list(set(weighted))
|
||||
random.shuffle(pick_pool)
|
||||
for _ in range(min(gw["winners_count"], len(pick_pool))):
|
||||
if not pick_pool:
|
||||
break
|
||||
weights = [weighted.count(uid) for uid in pick_pool]
|
||||
winner = random.choices(pick_pool, weights=weights, k=1)[0]
|
||||
winners.append(winner)
|
||||
pick_pool.remove(winner)
|
||||
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
if gw_id in gws:
|
||||
gws[gw_id]["ended"] = True
|
||||
gws[gw_id]["ended_at"] = ts_now()
|
||||
gws[gw_id]["winners_list"] = winners
|
||||
|
||||
async with self.give_config.guild(guild).stats() as stats:
|
||||
stats["total_winners"] = stats.get("total_winners", 0) + len(winners)
|
||||
|
||||
channel = guild.get_channel(gw["channel_id"])
|
||||
if not channel:
|
||||
return
|
||||
|
||||
# Build weighted entries (bonus roles)
|
||||
weighted = []
|
||||
for uid in gw["entries"]:
|
||||
weight = 1
|
||||
member = guild.get_member(uid)
|
||||
if member:
|
||||
for role_id_str, extra in gw.get("bonus_roles", {}).items():
|
||||
role = guild.get_role(int(role_id_str))
|
||||
if role and role in member.roles:
|
||||
weight += extra
|
||||
weighted.extend([uid] * weight)
|
||||
|
||||
winners_count = min(gw["winners_count"], len(set(gw["entries"])))
|
||||
winners = []
|
||||
pool = list(set(weighted))
|
||||
if pool:
|
||||
# Weighted selection without replacement
|
||||
selected = set()
|
||||
attempts = 0
|
||||
while len(winners) < winners_count and attempts < 1000:
|
||||
pick = random.choice(weighted)
|
||||
if pick not in selected:
|
||||
selected.add(pick)
|
||||
winners.append(pick)
|
||||
attempts += 1
|
||||
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
if gw_id in gws:
|
||||
gws[gw_id]["won_by"] = winners
|
||||
|
||||
if winners:
|
||||
winner_mentions = ", ".join(f"<@{w}>" for w in winners)
|
||||
end_msg = data.get("end_message", "🎉 Congratulations {winners}! You won **{prize}**!")
|
||||
end_msg = end_msg.replace("{winners}", winner_mentions).replace("{prize}", gw["prize"])
|
||||
end_embed = discord.Embed(
|
||||
title=f"🎉 {gw['prize']}", colour=discord.Colour(gw.get("colour", Clr.GIVE.value)),
|
||||
description=f"**Winners:** {winner_mentions}\n\nHosted by: <@{gw['host_id']}>",
|
||||
)
|
||||
end_embed.set_footer(text=f"Ended · {len(entries)} total entries")
|
||||
if gw.get("image_url"):
|
||||
end_embed.set_image(url=gw["image_url"])
|
||||
else:
|
||||
end_msg = "No valid entries — no winner."
|
||||
winner_mentions = "None"
|
||||
end_embed = discord.Embed(
|
||||
title=f"🎉 {gw['prize']}", colour=Clr.ERROR,
|
||||
description="No valid entries. Nobody won.",
|
||||
)
|
||||
|
||||
# Update embed
|
||||
try:
|
||||
msg = await channel.fetch_message(gw["message_id"])
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0]
|
||||
embed.title = "🎉 GIVEAWAY ENDED 🎉"
|
||||
embed.colour = discord.Colour(0x2F3136)
|
||||
embed.description = f"**{gw['prize']}**\n\nWinner(s): {winner_mentions}"
|
||||
embed.set_footer(text=f"Ended · {len(gw['entries'])} entries")
|
||||
embed.timestamp = datetime.datetime.now(datetime.timezone.utc)
|
||||
view = GiveawayEndedView(self, gw_id)
|
||||
await msg.edit(embed=embed, view=view)
|
||||
await msg.edit(embed=end_embed, view=GiveawayRerollView(self, gw_id) if winners else None)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await safe_send(channel, end_msg)
|
||||
if winners:
|
||||
await channel.send(
|
||||
f"🎉 Congratulations {winner_mentions}! You won **{gw['prize']}**!",
|
||||
)
|
||||
dm_enabled = await self.give_config.guild(guild).dm_winners()
|
||||
if dm_enabled:
|
||||
for w_id in winners:
|
||||
member = guild.get_member(w_id)
|
||||
if member:
|
||||
await safe_dm(member, embed=discord.Embed(
|
||||
title="🎉 You Won!",
|
||||
description=f"You won **{gw['prize']}** in **{guild.name}**!\nPlease contact the host.",
|
||||
colour=Clr.GIVE,
|
||||
))
|
||||
|
||||
# DM winners
|
||||
if gw.get("dm_winner", True) or data.get("dm_winners", True):
|
||||
for w in winners:
|
||||
user = guild.get_member(w)
|
||||
if user:
|
||||
await safe_dm(user, embed=discord.Embed(
|
||||
title="🎉 You Won!",
|
||||
description=f"You won **{gw['prize']}** in **{guild.name}**!",
|
||||
dm_host = await self.give_config.guild(guild).dm_host_on_end()
|
||||
if dm_host:
|
||||
host = guild.get_member(gw["host_id"])
|
||||
if host:
|
||||
await safe_dm(host, embed=discord.Embed(
|
||||
title="🎉 Giveaway Ended",
|
||||
description=f"**{gw['prize']}** has ended.\nWinners: {winner_mentions}\nEntries: {len(entries)}",
|
||||
colour=Clr.GIVE,
|
||||
))
|
||||
|
||||
# DM host
|
||||
if gw.get("dm_host", True) or data.get("dm_hosts", True):
|
||||
host = guild.get_member(gw["host_id"])
|
||||
if host:
|
||||
await safe_dm(host, embed=discord.Embed(
|
||||
title="🎉 Giveaway Ended",
|
||||
description=f"Your giveaway for **{gw['prize']}** ended.\nWinner(s): {winner_mentions}\nEntries: {len(gw['entries'])}",
|
||||
colour=Clr.GIVE,
|
||||
))
|
||||
log_ch_id = await self.give_config.guild(guild).log_channel()
|
||||
if log_ch_id:
|
||||
log_ch = guild.get_channel(log_ch_id)
|
||||
if log_ch:
|
||||
le = discord.Embed(title="🎉 Giveaway Ended", colour=Clr.GIVE)
|
||||
le.add_field(name="Prize", value=gw["prize"], inline=True)
|
||||
le.add_field(name="Entries", value=str(len(entries)), inline=True)
|
||||
le.add_field(name="Winners", value=winner_mentions if winners else "None", inline=False)
|
||||
await safe_send(log_ch, embed=le)
|
||||
|
||||
# Recurring
|
||||
if gw.get("recurring_interval", 0) > 0:
|
||||
new_gw_id = short_id(10)
|
||||
new_ends = ts_now() + gw["recurring_interval"]
|
||||
new_gw = dict(gw)
|
||||
new_gw["ended"] = False
|
||||
new_gw["entries"] = []
|
||||
new_gw["won_by"] = []
|
||||
new_gw["ends_at"] = new_ends
|
||||
new_gw["started_at"] = ts_now()
|
||||
new_gw["recurring_count"] = gw.get("recurring_count", 0) + 1
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🎉 GIVEAWAY 🎉",
|
||||
description=f"**{gw['prize']}**\n\n{gw.get('description', '')}",
|
||||
colour=Clr.GIVE,
|
||||
timestamp=datetime.datetime.fromtimestamp(new_ends, tz=datetime.timezone.utc),
|
||||
)
|
||||
embed.add_field(name="Ends", value=ts_relative(new_ends), inline=True)
|
||||
embed.add_field(name="Winners", value=str(gw["winners_count"]), inline=True)
|
||||
embed.add_field(name="Hosted by", value=f"<@{gw['host_id']}>", inline=True)
|
||||
embed.add_field(name="Entries", value="0", inline=True)
|
||||
embed.set_footer(text=f"ID: {new_gw_id} · Recurring #{new_gw['recurring_count']} · Ends at")
|
||||
|
||||
view = GiveawayView(self, new_gw_id)
|
||||
new_msg = await channel.send(embed=embed, view=view)
|
||||
new_gw["message_id"] = new_msg.id
|
||||
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
gws[new_gw_id] = new_gw
|
||||
|
||||
self._schedule_gw_end(guild.id, new_gw_id, new_ends)
|
||||
|
||||
async def _reroll_giveaway(self, interaction: discord.Interaction, gw_id: str):
|
||||
async def _reroll_giveaway(self, interaction, gw_id: str):
|
||||
guild = interaction.guild
|
||||
data = await self.give_config.guild(guild).all()
|
||||
gw = data["giveaways"].get(gw_id)
|
||||
if not gw:
|
||||
return await interaction.response.send_message("Not found.", ephemeral=True)
|
||||
if not gw["ended"]:
|
||||
return await interaction.response.send_message("Giveaway hasn't ended yet.", ephemeral=True)
|
||||
|
||||
pool = [uid for uid in gw["entries"] if uid not in gw.get("won_by", [])]
|
||||
if not pool:
|
||||
return await interaction.response.send_message("No eligible entries for reroll.", ephemeral=True)
|
||||
|
||||
new_winner = random.choice(pool)
|
||||
if not gw or not gw["ended"]:
|
||||
try:
|
||||
return await interaction.response.send_message("Giveaway not found or still active.", ephemeral=True)
|
||||
except Exception:
|
||||
return
|
||||
entries = [e for e in gw["entries"] if e not in gw.get("winners_list", [])]
|
||||
if not entries:
|
||||
try:
|
||||
return await interaction.response.send_message("No eligible entries to reroll.", ephemeral=True)
|
||||
except Exception:
|
||||
return
|
||||
winner = random.choice(entries)
|
||||
async with self.give_config.guild(guild).giveaways() as gws:
|
||||
if gw_id in gws:
|
||||
gws[gw_id]["won_by"].append(new_winner)
|
||||
gws[gw_id]["winners_list"].append(winner)
|
||||
channel = guild.get_channel(gw["channel_id"])
|
||||
if channel:
|
||||
await channel.send(f"🔄 New winner: <@{winner}>! Congratulations!")
|
||||
try:
|
||||
await interaction.response.send_message(f"Rerolled! New winner: <@{winner}>", ephemeral=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(f"🎉 New winner: <@{new_winner}> — **{gw['prize']}**!")
|
||||
# ── Template management ────────────────────────────────────────────────
|
||||
async def _save_gw_template(self, guild, name: str, data: dict):
|
||||
async with self.give_config.guild(guild).templates() as templates:
|
||||
templates[name] = data
|
||||
|
||||
user = guild.get_member(new_winner)
|
||||
if user:
|
||||
await safe_dm(user, embed=discord.Embed(
|
||||
title="🎉 You Won (Reroll)!",
|
||||
description=f"You won **{gw['prize']}** in **{guild.name}** from a reroll!",
|
||||
colour=Clr.GIVE,
|
||||
))
|
||||
|
||||
async def _giveaway_info(self, interaction: discord.Interaction, gw_id: str):
|
||||
data = await self.give_config.guild(interaction.guild).all()
|
||||
gw = data["giveaways"].get(gw_id)
|
||||
if not gw:
|
||||
return await interaction.response.send_message("Not found.", ephemeral=True)
|
||||
|
||||
embed = discord.Embed(title=f"🎉 {gw['prize']}", colour=Clr.GIVE)
|
||||
embed.add_field(name="Entries", value=str(len(gw["entries"])), inline=True)
|
||||
embed.add_field(name="Winners", value=str(gw["winners_count"]), inline=True)
|
||||
embed.add_field(name="Ends", value=ts_relative(gw["ends_at"]) if not gw["ended"] else "Ended", inline=True)
|
||||
embed.add_field(name="Host", value=f"<@{gw['host_id']}>", inline=True)
|
||||
|
||||
reqs = []
|
||||
if gw.get("require_role"):
|
||||
reqs.append(f"Role: <@&{gw['require_role']}>")
|
||||
if gw.get("require_account_days"):
|
||||
reqs.append(f"Account age: {gw['require_account_days']}d+")
|
||||
if gw.get("require_server_days"):
|
||||
reqs.append(f"Server age: {gw['require_server_days']}d+")
|
||||
if reqs:
|
||||
embed.add_field(name="Requirements", value="\n".join(reqs), inline=False)
|
||||
|
||||
if gw.get("bonus_roles"):
|
||||
bonus = "\n".join(f"<@&{rid}>: +{extra}" for rid, extra in gw["bonus_roles"].items())
|
||||
embed.add_field(name="Bonus Entries", value=bonus, inline=False)
|
||||
|
||||
in_gw = interaction.user.id in gw["entries"]
|
||||
embed.set_footer(text=f"{'✅ You are entered' if in_gw else '❌ Not entered'} · ID: {gw_id}")
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
async def _load_gw_template(self, guild, name: str) -> dict | None:
|
||||
data = await self.give_config.guild(guild).templates()
|
||||
return data.get(name)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"author": ["everestmcarthur"],
|
||||
"install_msg": "Thanks for installing **NexusCore v1.0.0** — The ultimate all-in-one server management cog!\n\n**Quick Start:**\n• `[p]nexus dashboard` — View module status\n• `[p]ticket setup #logs Category` — Set up tickets\n• `[p]apply setup #review` — Set up applications\n• `[p]suggest setup #suggestions` — Set up suggestions\n• `[p]roles create #channel button Title` — Create a role panel\n• `[p]gw start #channel 1d 1 Prize` — Start a giveaway\n• `[p]serverlog enable #logs` — Enable logging\n• `[p]nmod setup #modlog` — Set up moderation\n• `[p]eco balance` — Check your balance\n\nFull help: `[p]help NexusCore`",
|
||||
"install_msg": "Thanks for installing **NexusCore v2.0.0** — The ultimate all-in-one server management cog!\n\n**Modules (10):** Tickets, Applications, Suggestions, Reaction Roles, Giveaways, Server Logging, Moderation, Economy, Embed Builder, Dashboard Integration.\n\n**Quick Start:**\n• `[p]nexus` — Module overview & version\n• `[p]ticket setup #category #logs` — Set up tickets\n• `[p]apply setup #review` — Set up applications\n• `[p]suggest setup #suggestions` — Set up suggestions\n• `[p]roles create #channel button Title` — Create a role panel\n• `[p]gw start #channel 1d 1 Prize` — Start a giveaway\n• `[p]serverlog enable #logs` — Enable logging (30+ events)\n• `[p]nmod setup #modlog` — Set up moderation\n• `[p]eco balance` — Check your balance\n• `[p]eb create` — Interactive embed builder\n\n100+ commands. Full help: `[p]help NexusCore`",
|
||||
"name": "NexusCore",
|
||||
"disabled": false,
|
||||
"short": "The ultimate all-in-one: Tickets, Applications, Suggestions, Reaction Roles, Giveaways, Logging, Moderation & Economy.",
|
||||
"description": "NexusCore v1.0.0 combines eight powerful server management systems into one professional cog. TICKETS: Panel-based creation with button/select/modal flows, categories with custom questions, staff claim system, priority levels (low/medium/high/urgent), HTML transcript export, auto-close, feedback ratings, DM notifications, blacklisting, per-user limits. APPLICATIONS: Multi-type forms with modal-based questions (supports 10+ questions via multi-page modals), review system with accept/deny/interview buttons, auto-role on accept, staff notes, auto-thread for discussion, cooldowns, account/server age requirements, DM results. SUGGESTIONS: Voting with buttons or reactions, 8 status types (pending/approved/denied/considered/implemented/in_progress/duplicate/invalid), anonymous mode, auto-thread discussion, staff responses, category support, top suggestions leaderboard, status-specific channels. REACTION ROLES: Three modes (button/select/reaction), exclusive groups, role requirements, blacklisted roles, sticky roles, temporary roles, max picks per panel, DM confirmation, persistent across restarts. GIVEAWAYS: Button-entry with requirements (role/account age/server age), bonus entries per role, multiple winners, reroll, drop giveaways (first N to click), recurring giveaways, DM notifications, blacklisting. LOGGING: 20+ event types (messages, members, roles, channels, voice, invites, emoji), per-event channel routing, message content cache for deleted messages, invite tracking, ignore filters (channels/roles/users/bots), bulk delete with file export. MODERATION: Case system with numbered entries, warn system with auto-escalation (configurable thresholds to mute/tempban/ban), timeout/kick/ban/softban/tempban, staff notes, user history, purge, channel/server lockdown, anti-raid (join flood detection), anti-nuke (mass delete protection), auto-mod (anti-spam/caps/invites/links/mentions/newlines), appeal system with modals. ECONOMY: Wallet + bank with deposit/withdraw, daily/weekly rewards with streak bonuses, work/crime with custom messages, rob other users, coinflip/slots/blackjack gambling with interactive views, shop system (items + role items), inventory, heist mini-game (multiplayer), pet system (6 types, leveling, feeding, daily earnings), leaderboard, pay/transfer with tax, admin controls, configurable currency name/emoji.",
|
||||
"short": "The ultimate all-in-one: Tickets, Applications, Suggestions, Reaction Roles, Giveaways, Logging, Moderation, Economy, Embed Builder & Dashboard. 100+ commands.",
|
||||
"description": "NexusCore v2.0.0 combines ten powerful server management systems into one professional cog. NEW IN V2: Embed Builder (webhook-based, Sapphire-style, templates, scheduling, variables), Dashboard Integration (web config for all modules), expanded logging (30+ event types, emoji/thread/sticker/permission/webhook/scheduled events, retention, search), expanded moderation (quarantine, reputation, warning decay, staff leaderboard, punishment templates, cross-server ban sync, appeal system), expanded economy (roulette, dice, lottery, stock market, fishing, mining, crafting, achievements, income roles, auction house, gambling stats), expanded tickets (auto-close, tags, HTML transcripts, staff tracking, priority, feedback, reopen, archive), expanded applications (staff voting, bulk actions, webhook notifications, templates, conditional questions), expanded suggestions (edit, merge duplicates, 10 statuses, tag system, auto-approve/deny thresholds), expanded reaction roles (verification panels, exclusive groups, sticky/temp roles, clone, stats), expanded giveaways (drop mode, bonus entries, templates, custom embeds, DM notifications). ORIGINAL: TICKETS: Panel-based creation with button/select/modal flows, categories with custom questions, staff claim system, priority levels (low/medium/high/urgent), HTML transcript export, auto-close, feedback ratings, DM notifications, blacklisting, per-user limits. APPLICATIONS: Multi-type forms with modal-based questions (supports 10+ questions via multi-page modals), review system with accept/deny/interview buttons, auto-role on accept, staff notes, auto-thread for discussion, cooldowns, account/server age requirements, DM results. SUGGESTIONS: Voting with buttons or reactions, 8 status types (pending/approved/denied/considered/implemented/in_progress/duplicate/invalid), anonymous mode, auto-thread discussion, staff responses, category support, top suggestions leaderboard, status-specific channels. REACTION ROLES: Three modes (button/select/reaction), exclusive groups, role requirements, blacklisted roles, sticky roles, temporary roles, max picks per panel, DM confirmation, persistent across restarts. GIVEAWAYS: Button-entry with requirements (role/account age/server age), bonus entries per role, multiple winners, reroll, drop giveaways (first N to click), recurring giveaways, DM notifications, blacklisting. LOGGING: 20+ event types (messages, members, roles, channels, voice, invites, emoji), per-event channel routing, message content cache for deleted messages, invite tracking, ignore filters (channels/roles/users/bots), bulk delete with file export. MODERATION: Case system with numbered entries, warn system with auto-escalation (configurable thresholds to mute/tempban/ban), timeout/kick/ban/softban/tempban, staff notes, user history, purge, channel/server lockdown, anti-raid (join flood detection), anti-nuke (mass delete protection), auto-mod (anti-spam/caps/invites/links/mentions/newlines), appeal system with modals. ECONOMY: Wallet + bank with deposit/withdraw, daily/weekly rewards with streak bonuses, work/crime with custom messages, rob other users, coinflip/slots/blackjack gambling with interactive views, shop system (items + role items), inventory, heist mini-game (multiplayer), pet system (6 types, leveling, feeding, daily earnings), leaderboard, pay/transfer with tax, admin controls, configurable currency name/emoji.",
|
||||
"tags": [
|
||||
"tickets", "applications", "suggestions", "reactionroles", "giveaway",
|
||||
"logging", "moderation", "economy", "modlog", "warns", "antiraid",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
"""NexusCore — Reaction roles with buttons, select menus, reactions, exclusive groups, requirements."""
|
||||
"""NexusCore — Reaction roles v2: buttons, select, reactions, exclusive groups, requirements,
|
||||
multi-page menus, custom embeds, requirement chains, stats, conflict removal, clone, verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -10,80 +11,57 @@ from redbot.core import Config, commands
|
|||
|
||||
from .utils import (
|
||||
Clr, ok_embed, err_embed, info_embed, short_id, ts_now,
|
||||
safe_send, safe_dm,
|
||||
safe_send, safe_dm, chunk_list,
|
||||
)
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
RR_DEFAULTS_GUILD = {
|
||||
"enabled": True,
|
||||
"panels": {},
|
||||
# panel_id -> {
|
||||
# channel_id, message_id, title, description, colour, image, thumbnail,
|
||||
# mode: "button" | "select" | "reaction",
|
||||
# roles: [{role_id, label, emoji, description, style, group, required_role, blacklisted_role}],
|
||||
# exclusive_groups: {group_name: max_picks},
|
||||
# max_roles: 0, # 0 = unlimited
|
||||
# dm_confirm: True,
|
||||
# require_role: None,
|
||||
# blacklist_role: None,
|
||||
# sticky: False,
|
||||
# temp_minutes: 0,
|
||||
# }
|
||||
"log_channel": None,
|
||||
"dm_confirm": True,
|
||||
"stats": {}, # role_id -> {"added": 0, "removed": 0}
|
||||
"verification_panels": {},
|
||||
# panel_id -> {channel_id, message_id, role_id, button_label, button_style}
|
||||
}
|
||||
|
||||
|
||||
# ── Dynamic view builders ─────────────────────────────────────────────────
|
||||
def build_button_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
|
||||
view = discord.ui.View(timeout=None)
|
||||
|
||||
for i, role_entry in enumerate(panel_data.get("roles", [])[:25]):
|
||||
style_map = {
|
||||
"primary": discord.ButtonStyle.primary,
|
||||
"secondary": discord.ButtonStyle.secondary,
|
||||
"success": discord.ButtonStyle.success,
|
||||
"danger": discord.ButtonStyle.danger,
|
||||
"primary": discord.ButtonStyle.primary, "secondary": discord.ButtonStyle.secondary,
|
||||
"success": discord.ButtonStyle.success, "danger": discord.ButtonStyle.danger,
|
||||
}
|
||||
style = style_map.get(role_entry.get("style", "primary"), discord.ButtonStyle.primary)
|
||||
emoji = role_entry.get("emoji")
|
||||
|
||||
button = discord.ui.Button(
|
||||
label=role_entry.get("label", "Role"),
|
||||
style=style,
|
||||
emoji=emoji,
|
||||
label=role_entry.get("label", "Role"), style=style, emoji=emoji,
|
||||
custom_id=f"nexus_rr_{panel_id}_{i}",
|
||||
)
|
||||
|
||||
async def callback(interaction, idx=i, pid=panel_id):
|
||||
await cog._handle_role_toggle(interaction, pid, idx)
|
||||
button.callback = callback
|
||||
view.add_item(button)
|
||||
|
||||
return view
|
||||
|
||||
|
||||
def build_select_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
|
||||
view = discord.ui.View(timeout=None)
|
||||
max_roles = panel_data.get("max_roles", 0)
|
||||
|
||||
options = []
|
||||
for i, role_entry in enumerate(panel_data.get("roles", [])[:25]):
|
||||
options.append(discord.SelectOption(
|
||||
label=role_entry.get("label", "Role"),
|
||||
value=str(i),
|
||||
label=role_entry.get("label", "Role"), value=str(i),
|
||||
description=(role_entry.get("description", "") or "")[:100],
|
||||
emoji=role_entry.get("emoji"),
|
||||
))
|
||||
|
||||
select = discord.ui.Select(
|
||||
placeholder="Select roles...",
|
||||
options=options,
|
||||
min_values=0,
|
||||
placeholder="Select roles...", options=options, min_values=0,
|
||||
max_values=min(len(options), max_roles) if max_roles else len(options),
|
||||
custom_id=f"nexus_rr_sel_{panel_id}",
|
||||
)
|
||||
|
||||
async def callback(interaction):
|
||||
await cog._handle_select_roles(interaction, panel_id, [int(v) for v in select.values])
|
||||
select.callback = callback
|
||||
|
|
@ -91,21 +69,37 @@ def build_select_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
|
|||
return view
|
||||
|
||||
|
||||
def build_verification_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
|
||||
"""Verification button that grants a role on click."""
|
||||
view = discord.ui.View(timeout=None)
|
||||
style_map = {
|
||||
"primary": discord.ButtonStyle.primary, "secondary": discord.ButtonStyle.secondary,
|
||||
"success": discord.ButtonStyle.success, "danger": discord.ButtonStyle.danger,
|
||||
}
|
||||
style = style_map.get(panel_data.get("button_style", "success"), discord.ButtonStyle.success)
|
||||
button = discord.ui.Button(
|
||||
label=panel_data.get("button_label", "✅ Verify"), style=style,
|
||||
custom_id=f"nexus_verify_{panel_id}",
|
||||
)
|
||||
async def callback(interaction):
|
||||
await cog._handle_verification(interaction, panel_id)
|
||||
button.callback = callback
|
||||
view.add_item(button)
|
||||
return view
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class ReactionRolesMixin:
|
||||
"""Reaction roles mixin."""
|
||||
"""Reaction roles mixin — v2 with verification, stats, cloning, multi-page."""
|
||||
|
||||
def _init_reaction_roles(self, bot):
|
||||
self.rr_config = Config.get_conf(
|
||||
None, identifier=900004, cog_name="NexusCoreRR"
|
||||
)
|
||||
self.rr_config = Config.get_conf(None, identifier=900004, cog_name="NexusCoreRR")
|
||||
self.rr_config.register_guild(**RR_DEFAULTS_GUILD)
|
||||
self._rr_views = {} # panel_id -> View
|
||||
self._rr_temp_tasks = {} # (user_id, role_id) -> asyncio.Task
|
||||
self._rr_views = {}
|
||||
self._rr_temp_tasks = {}
|
||||
self.bot = bot
|
||||
|
||||
async def _load_rr_panels(self):
|
||||
"""Re-register persistent views on cog load."""
|
||||
all_guilds = await self.rr_config.all_guilds()
|
||||
for guild_id, gdata in all_guilds.items():
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
|
|
@ -121,6 +115,9 @@ class ReactionRolesMixin:
|
|||
continue
|
||||
self._rr_views[panel_id] = view
|
||||
self.bot.add_view(view, message_id=panel.get("message_id"))
|
||||
for vpid, vp in gdata.get("verification_panels", {}).items():
|
||||
view = build_verification_view(self, vpid, vp)
|
||||
self.bot.add_view(view, message_id=vp.get("message_id"))
|
||||
|
||||
async def _create_rr_panel(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel,
|
||||
|
|
@ -129,40 +126,25 @@ class ReactionRolesMixin:
|
|||
) -> str:
|
||||
panel_id = short_id(10)
|
||||
panel_data = {
|
||||
"channel_id": channel.id,
|
||||
"message_id": None,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"channel_id": channel.id, "message_id": None,
|
||||
"title": title, "description": description,
|
||||
"colour": (colour or Clr.ROLES).value,
|
||||
"image": image,
|
||||
"thumbnail": thumbnail,
|
||||
"mode": mode,
|
||||
"roles": [],
|
||||
"exclusive_groups": {},
|
||||
"max_roles": 0,
|
||||
"dm_confirm": True,
|
||||
"require_role": None,
|
||||
"blacklist_role": None,
|
||||
"sticky": False,
|
||||
"temp_minutes": 0,
|
||||
"image": image, "thumbnail": thumbnail,
|
||||
"mode": mode, "roles": [], "exclusive_groups": {},
|
||||
"max_roles": 0, "dm_confirm": True,
|
||||
"require_role": None, "blacklist_role": None,
|
||||
"sticky": False, "temp_minutes": 0,
|
||||
}
|
||||
|
||||
embed = discord.Embed(
|
||||
title=title, description=description,
|
||||
colour=colour or Clr.ROLES,
|
||||
)
|
||||
embed = discord.Embed(title=title, description=description, colour=colour or Clr.ROLES)
|
||||
if image:
|
||||
embed.set_image(url=image)
|
||||
if thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
embed.set_footer(text="Select your roles below")
|
||||
|
||||
msg = await channel.send(embed=embed)
|
||||
panel_data["message_id"] = msg.id
|
||||
|
||||
async with self.rr_config.guild(ctx.guild).panels() as panels:
|
||||
panels[panel_id] = panel_data
|
||||
|
||||
return panel_id
|
||||
|
||||
async def _add_role_to_panel(
|
||||
|
|
@ -176,19 +158,13 @@ class ReactionRolesMixin:
|
|||
panel = panels.get(panel_id)
|
||||
if not panel:
|
||||
return False
|
||||
|
||||
panel["roles"].append({
|
||||
"role_id": role.id,
|
||||
"label": label or role.name,
|
||||
"emoji": emoji,
|
||||
"description": description,
|
||||
"style": style,
|
||||
"group": group,
|
||||
"required_role": required_role,
|
||||
"blacklisted_role": blacklisted_role,
|
||||
"role_id": role.id, "label": label or role.name,
|
||||
"emoji": emoji, "description": description,
|
||||
"style": style, "group": group,
|
||||
"required_role": required_role, "blacklisted_role": blacklisted_role,
|
||||
})
|
||||
panels[panel_id] = panel
|
||||
|
||||
await self._refresh_rr_panel(guild, panel_id)
|
||||
return True
|
||||
|
||||
|
|
@ -197,11 +173,9 @@ class ReactionRolesMixin:
|
|||
panel = data.get(panel_id)
|
||||
if not panel:
|
||||
return
|
||||
|
||||
channel = guild.get_channel(panel["channel_id"])
|
||||
if not channel:
|
||||
return
|
||||
|
||||
mode = panel.get("mode", "button")
|
||||
if mode == "button":
|
||||
view = build_button_view(self, panel_id, panel)
|
||||
|
|
@ -209,33 +183,23 @@ class ReactionRolesMixin:
|
|||
view = build_select_view(self, panel_id, panel)
|
||||
else:
|
||||
view = None
|
||||
|
||||
if view:
|
||||
self._rr_views[panel_id] = view
|
||||
self.bot.add_view(view, message_id=panel.get("message_id"))
|
||||
|
||||
embed = discord.Embed(
|
||||
title=panel["title"],
|
||||
description=panel["description"],
|
||||
colour=discord.Colour(panel["colour"]),
|
||||
)
|
||||
embed = discord.Embed(title=panel["title"], description=panel["description"], colour=discord.Colour(panel["colour"]))
|
||||
if panel.get("image"):
|
||||
embed.set_image(url=panel["image"])
|
||||
if panel.get("thumbnail"):
|
||||
embed.set_thumbnail(url=panel["thumbnail"])
|
||||
|
||||
role_lines = []
|
||||
for r in panel.get("roles", []):
|
||||
emoji_str = f"{r['emoji']} " if r.get("emoji") else ""
|
||||
role = guild.get_role(r["role_id"])
|
||||
role_mention = role.mention if role else f"<@&{r['role_id']}>"
|
||||
role_lines.append(f"{emoji_str}{role_mention} — {r.get('description', r.get('label', ''))}")
|
||||
|
||||
if role_lines:
|
||||
embed.add_field(name="Available Roles", value="\n".join(role_lines), inline=False)
|
||||
|
||||
embed.set_footer(text="Select your roles below")
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(panel["message_id"])
|
||||
await msg.edit(embed=embed, view=view)
|
||||
|
|
@ -249,11 +213,9 @@ class ReactionRolesMixin:
|
|||
panel = data.get(panel_id)
|
||||
if not panel:
|
||||
return await interaction.response.send_message("Panel not found.", ephemeral=True)
|
||||
|
||||
roles_list = panel.get("roles", [])
|
||||
if role_idx >= len(roles_list):
|
||||
return await interaction.response.send_message("Role not found.", ephemeral=True)
|
||||
|
||||
role_entry = roles_list[role_idx]
|
||||
role = guild.get_role(role_entry["role_id"])
|
||||
if not role:
|
||||
|
|
@ -264,17 +226,14 @@ class ReactionRolesMixin:
|
|||
req = guild.get_role(panel["require_role"])
|
||||
if req and req not in member.roles:
|
||||
return await interaction.response.send_message(f"You need {req.mention} first.", ephemeral=True)
|
||||
|
||||
if panel.get("blacklist_role"):
|
||||
bl = guild.get_role(panel["blacklist_role"])
|
||||
if bl and bl in member.roles:
|
||||
return await interaction.response.send_message(f"You can't use this with {bl.mention}.", ephemeral=True)
|
||||
|
||||
if role_entry.get("required_role"):
|
||||
rr = guild.get_role(role_entry["required_role"])
|
||||
if rr and rr not in member.roles:
|
||||
return await interaction.response.send_message(f"You need {rr.mention} first.", ephemeral=True)
|
||||
|
||||
if role_entry.get("blacklisted_role"):
|
||||
br = guild.get_role(role_entry["blacklisted_role"])
|
||||
if br and br in member.roles:
|
||||
|
|
@ -288,7 +247,6 @@ class ReactionRolesMixin:
|
|||
action = "removed"
|
||||
emoji = "➖"
|
||||
else:
|
||||
# Exclusive group check
|
||||
group = role_entry.get("group")
|
||||
if group and group in panel.get("exclusive_groups", {}):
|
||||
max_picks = panel["exclusive_groups"][group]
|
||||
|
|
@ -297,20 +255,13 @@ class ReactionRolesMixin:
|
|||
if len(member_group_roles) >= max_picks:
|
||||
for old_role in member_group_roles:
|
||||
await member.remove_roles(old_role, reason="NexusCore exclusive group swap")
|
||||
|
||||
# Max roles check
|
||||
if panel.get("max_roles", 0) > 0:
|
||||
current_panel_roles = [guild.get_role(r["role_id"]) for r in roles_list if guild.get_role(r["role_id"]) in member.roles]
|
||||
if len(current_panel_roles) >= panel["max_roles"]:
|
||||
return await interaction.response.send_message(
|
||||
f"Max {panel['max_roles']} roles from this panel.", ephemeral=True
|
||||
)
|
||||
|
||||
return await interaction.response.send_message(f"Max {panel['max_roles']} roles from this panel.", ephemeral=True)
|
||||
await member.add_roles(role, reason="NexusCore reaction roles")
|
||||
action = "added"
|
||||
emoji = "➕"
|
||||
|
||||
# Temp role
|
||||
if panel.get("temp_minutes", 0) > 0:
|
||||
import asyncio
|
||||
async def remove_later():
|
||||
|
|
@ -321,7 +272,6 @@ class ReactionRolesMixin:
|
|||
pass
|
||||
task = asyncio.create_task(remove_later())
|
||||
self._rr_temp_tasks[(member.id, role.id)] = task
|
||||
|
||||
except discord.Forbidden:
|
||||
return await interaction.response.send_message("I don't have permission to manage that role.", ephemeral=True)
|
||||
except discord.HTTPException:
|
||||
|
|
@ -329,24 +279,25 @@ class ReactionRolesMixin:
|
|||
|
||||
await interaction.response.send_message(f"{emoji} **{role.name}** {action}!", ephemeral=True)
|
||||
|
||||
# DM confirm
|
||||
# Track stats
|
||||
async with self.rr_config.guild(guild).stats() as stats:
|
||||
rid = str(role.id)
|
||||
if rid not in stats:
|
||||
stats[rid] = {"added": 0, "removed": 0}
|
||||
stats[rid]["added" if action == "added" else "removed"] += 1
|
||||
|
||||
dm_confirm = panel.get("dm_confirm", True)
|
||||
if dm_confirm:
|
||||
await safe_dm(member, embed=discord.Embed(
|
||||
description=f"{emoji} Role **{role.name}** has been {action} in **{guild.name}**.",
|
||||
colour=Clr.ROLES,
|
||||
))
|
||||
description=f"{emoji} Role **{role.name}** has been {action} in **{guild.name}**.", colour=Clr.ROLES))
|
||||
|
||||
# Log
|
||||
log_ch_id = await self.rr_config.guild(guild).log_channel()
|
||||
if log_ch_id:
|
||||
log_ch = guild.get_channel(log_ch_id)
|
||||
if log_ch:
|
||||
le = discord.Embed(
|
||||
description=f"{emoji} {member.mention} — **{role.name}** {action}",
|
||||
colour=Clr.ROLES,
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
colour=Clr.ROLES, timestamp=datetime.datetime.now(datetime.timezone.utc))
|
||||
await safe_send(log_ch, embed=le)
|
||||
|
||||
async def _handle_select_roles(self, interaction: discord.Interaction, panel_id: str, selected: list[int]):
|
||||
|
|
@ -356,10 +307,8 @@ class ReactionRolesMixin:
|
|||
panel = data.get(panel_id)
|
||||
if not panel:
|
||||
return await interaction.response.send_message("Panel not found.", ephemeral=True)
|
||||
|
||||
roles_list = panel.get("roles", [])
|
||||
added, removed = [], []
|
||||
|
||||
for i, role_entry in enumerate(roles_list):
|
||||
role = guild.get_role(role_entry["role_id"])
|
||||
if not role:
|
||||
|
|
@ -378,7 +327,6 @@ class ReactionRolesMixin:
|
|||
removed.append(role.name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
parts = []
|
||||
if added:
|
||||
parts.append(f"➕ Added: {', '.join(added)}")
|
||||
|
|
@ -386,19 +334,91 @@ class ReactionRolesMixin:
|
|||
parts.append(f"➖ Removed: {', '.join(removed)}")
|
||||
if not parts:
|
||||
parts.append("No changes.")
|
||||
|
||||
await interaction.response.send_message("\n".join(parts), ephemeral=True)
|
||||
|
||||
# ── Reaction-based handling (on_raw_reaction_add / remove) ─────────────
|
||||
async def _handle_verification(self, interaction: discord.Interaction, panel_id: str):
|
||||
"""Handle verification button click — grant the configured role."""
|
||||
guild = interaction.guild
|
||||
data = await self.rr_config.guild(guild).verification_panels()
|
||||
vp = data.get(panel_id)
|
||||
if not vp:
|
||||
return await interaction.response.send_message("Verification panel not found.", ephemeral=True)
|
||||
role = guild.get_role(vp.get("role_id"))
|
||||
if not role:
|
||||
return await interaction.response.send_message("Verification role not found.", ephemeral=True)
|
||||
if role in interaction.user.roles:
|
||||
return await interaction.response.send_message("You're already verified!", ephemeral=True)
|
||||
try:
|
||||
await interaction.user.add_roles(role, reason="NexusCore verification")
|
||||
await interaction.response.send_message("✅ You've been verified!", ephemeral=True)
|
||||
except discord.Forbidden:
|
||||
await interaction.response.send_message("I can't assign the verification role.", ephemeral=True)
|
||||
|
||||
async def _create_verification_panel(
|
||||
self, ctx, channel: discord.TextChannel, role: discord.Role,
|
||||
label: str = "✅ Verify", style: str = "success", title: str = "Verification",
|
||||
description: str = "Click the button below to verify yourself!",
|
||||
) -> str:
|
||||
vpid = short_id(10)
|
||||
embed = discord.Embed(title=title, description=description, colour=Clr.ROLES)
|
||||
vp_data = {
|
||||
"channel_id": channel.id, "message_id": None,
|
||||
"role_id": role.id, "button_label": label, "button_style": style,
|
||||
}
|
||||
view = build_verification_view(self, vpid, vp_data)
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
vp_data["message_id"] = msg.id
|
||||
async with self.rr_config.guild(ctx.guild).verification_panels() as vps:
|
||||
vps[vpid] = vp_data
|
||||
self.bot.add_view(view, message_id=msg.id)
|
||||
return vpid
|
||||
|
||||
async def _clone_panel(self, guild, panel_id: str, target_channel: discord.TextChannel) -> str | None:
|
||||
"""Clone a role panel to another channel."""
|
||||
data = await self.rr_config.guild(guild).panels()
|
||||
panel = data.get(panel_id)
|
||||
if not panel:
|
||||
return None
|
||||
new_id = short_id(10)
|
||||
new_panel = dict(panel)
|
||||
new_panel["channel_id"] = target_channel.id
|
||||
embed = discord.Embed(
|
||||
title=panel["title"], description=panel["description"], colour=discord.Colour(panel["colour"]))
|
||||
if panel.get("image"):
|
||||
embed.set_image(url=panel["image"])
|
||||
if panel.get("thumbnail"):
|
||||
embed.set_thumbnail(url=panel["thumbnail"])
|
||||
role_lines = []
|
||||
for r in panel.get("roles", []):
|
||||
emoji_str = f"{r['emoji']} " if r.get("emoji") else ""
|
||||
ro = guild.get_role(r["role_id"])
|
||||
role_lines.append(f"{emoji_str}{ro.mention if ro else '?'} — {r.get('label', '')}")
|
||||
if role_lines:
|
||||
embed.add_field(name="Available Roles", value="\n".join(role_lines), inline=False)
|
||||
embed.set_footer(text="Select your roles below")
|
||||
mode = panel.get("mode", "button")
|
||||
if mode == "button":
|
||||
view = build_button_view(self, new_id, panel)
|
||||
elif mode == "select":
|
||||
view = build_select_view(self, new_id, panel)
|
||||
else:
|
||||
view = None
|
||||
msg = await target_channel.send(embed=embed, view=view)
|
||||
new_panel["message_id"] = msg.id
|
||||
async with self.rr_config.guild(guild).panels() as panels:
|
||||
panels[new_id] = new_panel
|
||||
if view:
|
||||
self.bot.add_view(view, message_id=msg.id)
|
||||
return new_id
|
||||
|
||||
# ── Reaction-based handling ────────────────────────────────────────────
|
||||
async def _handle_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||
guild = self.bot.get_guild(payload.guild_id)
|
||||
if not guild:
|
||||
return
|
||||
data = await self.rr_config.guild(guild).panels()
|
||||
for panel_id, panel in data.items():
|
||||
if panel.get("mode") != "reaction":
|
||||
continue
|
||||
if panel.get("message_id") != payload.message_id:
|
||||
if panel.get("mode") != "reaction" or panel.get("message_id") != payload.message_id:
|
||||
continue
|
||||
for role_entry in panel.get("roles", []):
|
||||
if str(payload.emoji) == role_entry.get("emoji") or payload.emoji.name == role_entry.get("emoji"):
|
||||
|
|
@ -416,9 +436,7 @@ class ReactionRolesMixin:
|
|||
return
|
||||
data = await self.rr_config.guild(guild).panels()
|
||||
for panel_id, panel in data.items():
|
||||
if panel.get("mode") != "reaction":
|
||||
continue
|
||||
if panel.get("message_id") != payload.message_id:
|
||||
if panel.get("mode") != "reaction" or panel.get("message_id") != payload.message_id:
|
||||
continue
|
||||
for role_entry in panel.get("roles", []):
|
||||
if str(payload.emoji) == role_entry.get("emoji") or payload.emoji.name == role_entry.get("emoji"):
|
||||
|
|
|
|||
|
|
@ -1,422 +1,82 @@
|
|||
"""NexusCore — Comprehensive server logging with message cache, event routing, ignore filters."""
|
||||
"""NexusCore — Server logging v2: 30+ event types, per-event channels, message cache,
|
||||
invite tracking, ignore filters, emoji/thread/sticker/permission/webhook/scheduled event,
|
||||
log retention, search, log formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from redbot.core import Config, commands
|
||||
|
||||
from .utils import Clr, safe_send, ts_now, ts_relative
|
||||
from .utils import (
|
||||
Clr, ok_embed, err_embed, info_embed, ts_now, ts_relative,
|
||||
safe_send, chunk_list,
|
||||
)
|
||||
|
||||
# ── Event types ────────────────────────────────────────────────────────────
|
||||
EVENT_TYPES = [
|
||||
"message_delete", "message_edit", "message_bulk_delete",
|
||||
"member_join", "member_leave", "member_update", "member_ban", "member_unban",
|
||||
"role_create", "role_delete", "role_update",
|
||||
"channel_create", "channel_delete", "channel_update",
|
||||
"voice_join", "voice_leave", "voice_move",
|
||||
"invite_create", "invite_delete",
|
||||
"emoji_update", "sticker_update",
|
||||
"thread_create", "thread_delete", "thread_update",
|
||||
"webhook_update",
|
||||
"scheduled_event_create", "scheduled_event_delete", "scheduled_event_update",
|
||||
"guild_update", "automod_action",
|
||||
"permission_update",
|
||||
"nickname_change",
|
||||
"timeout_add", "timeout_remove",
|
||||
]
|
||||
|
||||
EVENT_EMOJI = {
|
||||
"message_delete": "🗑️", "message_edit": "✏️", "message_bulk_delete": "🗑️",
|
||||
"member_join": "📥", "member_leave": "📤", "member_update": "👤", "member_ban": "🔨", "member_unban": "🔓",
|
||||
"role_create": "🏷️", "role_delete": "🏷️", "role_update": "🏷️",
|
||||
"channel_create": "📁", "channel_delete": "📁", "channel_update": "📁",
|
||||
"voice_join": "🔊", "voice_leave": "🔇", "voice_move": "🔀",
|
||||
"invite_create": "🔗", "invite_delete": "🔗",
|
||||
"emoji_update": "😀", "sticker_update": "🖼️",
|
||||
"thread_create": "🧵", "thread_delete": "🧵", "thread_update": "🧵",
|
||||
"webhook_update": "🪝", "scheduled_event_create": "📅",
|
||||
"scheduled_event_delete": "📅", "scheduled_event_update": "📅",
|
||||
"guild_update": "⚙️", "automod_action": "🤖",
|
||||
"permission_update": "🔒", "nickname_change": "📝",
|
||||
"timeout_add": "⏰", "timeout_remove": "⏰",
|
||||
}
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
LOG_DEFAULTS_GUILD = {
|
||||
"enabled": False,
|
||||
"default_channel": None,
|
||||
"channels": {},
|
||||
# event_type -> channel_id (override per event)
|
||||
# "message_edit", "message_delete", "bulk_delete",
|
||||
# "member_join", "member_leave", "member_ban", "member_unban",
|
||||
# "member_update", "role_create", "role_delete", "role_update",
|
||||
# "channel_create", "channel_delete", "channel_update",
|
||||
# "voice", "invite", "emoji", "sticker", "thread",
|
||||
# "server_update", "mod_action"
|
||||
"channels": {}, # event_type -> channel_id
|
||||
"ignore_channels": [],
|
||||
"ignore_roles": [],
|
||||
"ignore_users": [],
|
||||
"ignore_bots": True,
|
||||
"log_attachments": True,
|
||||
"log_embeds": False,
|
||||
"message_cache_size": 1000,
|
||||
"compact_mode": False,
|
||||
"enabled_events": {e: True for e in EVENT_TYPES},
|
||||
"invite_tracking": True,
|
||||
"invites_cache": {}, # code -> {uses, inviter_id}
|
||||
"message_cache_size": 200,
|
||||
"log_retention_days": 0, # 0 = forever
|
||||
"compact_mode": False,
|
||||
"show_avatar": True,
|
||||
"embed_colour": Clr.LOG.value,
|
||||
}
|
||||
|
||||
EVENT_TYPES = [
|
||||
"message_edit", "message_delete", "bulk_delete",
|
||||
"member_join", "member_leave", "member_ban", "member_unban",
|
||||
"member_update", "role_create", "role_delete", "role_update",
|
||||
"channel_create", "channel_delete", "channel_update",
|
||||
"voice", "invite", "emoji", "sticker", "thread",
|
||||
"server_update", "mod_action",
|
||||
]
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class ServerLogMixin:
|
||||
"""Server logging mixin — handles all Discord events for audit logging."""
|
||||
"""Server logging mixin — v2 with 30+ events, retention, search."""
|
||||
|
||||
def _init_logging(self, bot):
|
||||
self.log_config = Config.get_conf(
|
||||
None, identifier=900006, cog_name="NexusCoreLog"
|
||||
)
|
||||
def _init_serverlog(self, bot):
|
||||
self.log_config = Config.get_conf(None, identifier=900006, cog_name="NexusCoreLog")
|
||||
self.log_config.register_guild(**LOG_DEFAULTS_GUILD)
|
||||
self._msg_cache = {} # guild_id -> collections.OrderedDict of msg_id -> {content, author, channel, attachments}
|
||||
self._invite_cache = {} # guild_id -> {code: uses}
|
||||
self.bot = bot
|
||||
|
||||
def _get_cache(self, guild_id: int) -> collections.OrderedDict:
|
||||
if guild_id not in self._msg_cache:
|
||||
self._msg_cache[guild_id] = collections.OrderedDict()
|
||||
return self._msg_cache[guild_id]
|
||||
|
||||
async def _get_log_channel(self, guild: discord.Guild, event_type: str) -> discord.TextChannel | None:
|
||||
data = await self.log_config.guild(guild).all()
|
||||
if not data["enabled"]:
|
||||
return None
|
||||
ch_id = data["channels"].get(event_type) or data["default_channel"]
|
||||
if not ch_id:
|
||||
return None
|
||||
return guild.get_channel(ch_id)
|
||||
|
||||
async def _should_ignore(self, guild: discord.Guild, *, channel=None, member=None) -> bool:
|
||||
data = await self.log_config.guild(guild).all()
|
||||
if channel and channel.id in data["ignore_channels"]:
|
||||
return True
|
||||
if member:
|
||||
if data["ignore_bots"] and member.bot:
|
||||
return True
|
||||
if member.id in data["ignore_users"]:
|
||||
return True
|
||||
if any(r.id in data["ignore_roles"] for r in getattr(member, "roles", [])):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Message cache ──────────────────────────────────────────────────────
|
||||
async def _cache_message(self, message: discord.Message):
|
||||
if not message.guild:
|
||||
return
|
||||
cache = self._get_cache(message.guild.id)
|
||||
cache[message.id] = {
|
||||
"content": message.content,
|
||||
"author_id": message.author.id,
|
||||
"author_str": str(message.author),
|
||||
"channel_id": message.channel.id,
|
||||
"attachments": [a.url for a in message.attachments],
|
||||
"embeds": len(message.embeds),
|
||||
}
|
||||
data = await self.log_config.guild(message.guild).all()
|
||||
max_size = data.get("message_cache_size", 1000)
|
||||
while len(cache) > max_size:
|
||||
cache.popitem(last=False)
|
||||
|
||||
# ── Event handlers ─────────────────────────────────────────────────────
|
||||
async def _log_message_edit(self, before: discord.Message, after: discord.Message):
|
||||
if not after.guild or before.content == after.content:
|
||||
return
|
||||
if await self._should_ignore(after.guild, channel=after.channel, member=after.author):
|
||||
return
|
||||
log_ch = await self._get_log_channel(after.guild, "message_edit")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="✏️ Message Edited",
|
||||
colour=discord.Colour(0x3498DB),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(
|
||||
name=str(after.author),
|
||||
icon_url=after.author.display_avatar.url if after.author.display_avatar else None,
|
||||
)
|
||||
embed.add_field(name="Before", value=(before.content or "*empty*")[:1024], inline=False)
|
||||
embed.add_field(name="After", value=(after.content or "*empty*")[:1024], inline=False)
|
||||
embed.add_field(name="Channel", value=after.channel.mention, inline=True)
|
||||
embed.add_field(name="Author", value=after.author.mention, inline=True)
|
||||
embed.add_field(name="Jump", value=f"[Click]({after.jump_url})", inline=True)
|
||||
embed.set_footer(text=f"Message ID: {after.id} · User ID: {after.author.id}")
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_message_delete(self, message: discord.Message):
|
||||
if not message.guild:
|
||||
return
|
||||
if await self._should_ignore(message.guild, channel=message.channel, member=message.author):
|
||||
return
|
||||
log_ch = await self._get_log_channel(message.guild, "message_delete")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
content = message.content or ""
|
||||
# Try cache if content is empty
|
||||
if not content:
|
||||
cached = self._get_cache(message.guild.id).get(message.id)
|
||||
if cached:
|
||||
content = cached.get("content", "")
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🗑️ Message Deleted",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(
|
||||
name=str(message.author),
|
||||
icon_url=message.author.display_avatar.url if message.author.display_avatar else None,
|
||||
)
|
||||
if content:
|
||||
embed.add_field(name="Content", value=content[:1024], inline=False)
|
||||
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
|
||||
embed.add_field(name="Author", value=message.author.mention, inline=True)
|
||||
|
||||
data = await self.log_config.guild(message.guild).all()
|
||||
if data["log_attachments"] and message.attachments:
|
||||
att_text = "\n".join(f"[{a.filename}]({a.url})" for a in message.attachments[:5])
|
||||
embed.add_field(name="Attachments", value=att_text, inline=False)
|
||||
|
||||
embed.set_footer(text=f"Message ID: {message.id} · User ID: {message.author.id}")
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_bulk_delete(self, messages: list[discord.Message]):
|
||||
if not messages or not messages[0].guild:
|
||||
return
|
||||
guild = messages[0].guild
|
||||
log_ch = await self._get_log_channel(guild, "bulk_delete")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🗑️ Bulk Message Delete",
|
||||
description=f"**{len(messages)}** messages deleted in {messages[0].channel.mention}",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
|
||||
# Build text file of deleted messages
|
||||
lines = []
|
||||
for m in messages[:200]:
|
||||
ts = m.created_at.strftime("%H:%M:%S")
|
||||
lines.append(f"[{ts}] {m.author}: {m.content or '[no content]'}")
|
||||
content_txt = "\n".join(lines)
|
||||
|
||||
import io
|
||||
file = discord.File(io.BytesIO(content_txt.encode()), filename="bulk_delete.txt")
|
||||
await safe_send(log_ch, embed=embed, file=file)
|
||||
|
||||
async def _log_member_join(self, member: discord.Member):
|
||||
if await self._should_ignore(member.guild, member=member):
|
||||
return
|
||||
log_ch = await self._get_log_channel(member.guild, "member_join")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="📥 Member Joined",
|
||||
colour=discord.Colour(0x2ECC71),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(member), icon_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="Account Created", value=ts_relative(int(member.created_at.timestamp())), inline=True)
|
||||
embed.add_field(name="Member #", value=str(member.guild.member_count), inline=True)
|
||||
|
||||
age_days = (datetime.datetime.now(datetime.timezone.utc) - member.created_at).days
|
||||
if age_days < 7:
|
||||
embed.add_field(name="⚠️ New Account", value=f"Created {age_days} day(s) ago", inline=False)
|
||||
|
||||
embed.set_thumbnail(url=member.display_avatar.url if member.display_avatar else None)
|
||||
|
||||
# Invite tracking
|
||||
data = await self.log_config.guild(member.guild).all()
|
||||
if data.get("invite_tracking"):
|
||||
try:
|
||||
invites = await member.guild.invites()
|
||||
old_cache = self._invite_cache.get(member.guild.id, {})
|
||||
for inv in invites:
|
||||
old_uses = old_cache.get(inv.code, 0)
|
||||
if inv.uses > old_uses:
|
||||
embed.add_field(name="Invited by", value=f"{inv.inviter.mention if inv.inviter else 'Unknown'} (code: `{inv.code}`)", inline=False)
|
||||
break
|
||||
self._invite_cache[member.guild.id] = {inv.code: inv.uses for inv in invites}
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_member_leave(self, member: discord.Member):
|
||||
if await self._should_ignore(member.guild, member=member):
|
||||
return
|
||||
log_ch = await self._get_log_channel(member.guild, "member_leave")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="📤 Member Left",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(member), icon_url=member.display_avatar.url if member.display_avatar else None)
|
||||
embed.add_field(name="User", value=f"{member.mention} (`{member.id}`)", inline=True)
|
||||
if member.joined_at:
|
||||
embed.add_field(name="Joined", value=ts_relative(int(member.joined_at.timestamp())), inline=True)
|
||||
roles = [r.mention for r in member.roles if r != member.guild.default_role]
|
||||
if roles:
|
||||
embed.add_field(name="Roles", value=", ".join(roles[:20]), inline=False)
|
||||
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_member_ban(self, guild: discord.Guild, user: discord.User):
|
||||
log_ch = await self._get_log_channel(guild, "member_ban")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="🔨 Member Banned",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(user), icon_url=user.display_avatar.url if user.display_avatar else None)
|
||||
embed.add_field(name="User", value=f"{user.mention} (`{user.id}`)", inline=True)
|
||||
|
||||
try:
|
||||
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.ban):
|
||||
if entry.target and entry.target.id == user.id:
|
||||
embed.add_field(name="Banned by", value=entry.user.mention if entry.user else "Unknown", inline=True)
|
||||
if entry.reason:
|
||||
embed.add_field(name="Reason", value=entry.reason[:1024], inline=False)
|
||||
break
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_member_unban(self, guild: discord.Guild, user: discord.User):
|
||||
log_ch = await self._get_log_channel(guild, "member_unban")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="🔓 Member Unbanned",
|
||||
description=f"{user.mention} (`{user.id}`)",
|
||||
colour=discord.Colour(0x2ECC71),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_member_update(self, before: discord.Member, after: discord.Member):
|
||||
if await self._should_ignore(after.guild, member=after):
|
||||
return
|
||||
log_ch = await self._get_log_channel(after.guild, "member_update")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
changes = []
|
||||
|
||||
if before.nick != after.nick:
|
||||
changes.append(("Nickname", before.nick or "*None*", after.nick or "*None*"))
|
||||
|
||||
added_roles = set(after.roles) - set(before.roles)
|
||||
removed_roles = set(before.roles) - set(after.roles)
|
||||
if added_roles:
|
||||
changes.append(("Roles Added", "", ", ".join(r.mention for r in added_roles)))
|
||||
if removed_roles:
|
||||
changes.append(("Roles Removed", "", ", ".join(r.mention for r in removed_roles)))
|
||||
|
||||
if before.communication_disabled_until != after.communication_disabled_until:
|
||||
if after.communication_disabled_until and after.communication_disabled_until > datetime.datetime.now(datetime.timezone.utc):
|
||||
changes.append(("Timed Out", "No", ts_relative(int(after.communication_disabled_until.timestamp()))))
|
||||
elif before.communication_disabled_until:
|
||||
changes.append(("Timeout Removed", "", ""))
|
||||
|
||||
if not changes:
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="👤 Member Updated",
|
||||
colour=discord.Colour(0xF39C12),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(after), icon_url=after.display_avatar.url if after.display_avatar else None)
|
||||
for name, old, new in changes:
|
||||
if old:
|
||||
embed.add_field(name=name, value=f"`{old}` → `{new}`", inline=False)
|
||||
else:
|
||||
embed.add_field(name=name, value=new, inline=False)
|
||||
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_voice(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
|
||||
if await self._should_ignore(member.guild, member=member):
|
||||
return
|
||||
log_ch = await self._get_log_channel(member.guild, "voice")
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
if before.channel is None and after.channel:
|
||||
desc = f"🔊 **{member}** joined **{after.channel.name}**"
|
||||
colour = discord.Colour(0x2ECC71)
|
||||
elif before.channel and after.channel is None:
|
||||
desc = f"🔇 **{member}** left **{before.channel.name}**"
|
||||
colour = discord.Colour(0xE74C3C)
|
||||
elif before.channel and after.channel and before.channel != after.channel:
|
||||
desc = f"🔀 **{member}** moved from **{before.channel.name}** → **{after.channel.name}**"
|
||||
colour = discord.Colour(0xF39C12)
|
||||
elif before.self_mute != after.self_mute:
|
||||
desc = f"{'🔇' if after.self_mute else '🔊'} **{member}** {'self-muted' if after.self_mute else 'self-unmuted'}"
|
||||
colour = discord.Colour(0x95A5A6)
|
||||
elif before.self_deaf != after.self_deaf:
|
||||
desc = f"{'🔇' if after.self_deaf else '🔊'} **{member}** {'self-deafened' if after.self_deaf else 'self-undeafened'}"
|
||||
colour = discord.Colour(0x95A5A6)
|
||||
elif before.mute != after.mute:
|
||||
desc = f"{'🔇' if after.mute else '🔊'} **{member}** was {'server muted' if after.mute else 'server unmuted'}"
|
||||
colour = discord.Colour(0xE67E22)
|
||||
elif before.deaf != after.deaf:
|
||||
desc = f"{'🔇' if after.deaf else '🔊'} **{member}** was {'server deafened' if after.deaf else 'server undeafened'}"
|
||||
colour = discord.Colour(0xE67E22)
|
||||
else:
|
||||
return
|
||||
|
||||
embed = discord.Embed(description=desc, colour=colour, timestamp=datetime.datetime.now(datetime.timezone.utc))
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_channel_create(self, channel: discord.abc.GuildChannel):
|
||||
log_ch = await self._get_log_channel(channel.guild, "channel_create")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="📁 Channel Created",
|
||||
description=f"{channel.mention} (`{channel.name}`)\nType: {str(channel.type).title()}",
|
||||
colour=discord.Colour(0x2ECC71),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_channel_delete(self, channel: discord.abc.GuildChannel):
|
||||
log_ch = await self._get_log_channel(channel.guild, "channel_delete")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="📁 Channel Deleted",
|
||||
description=f"`{channel.name}` ({str(channel.type).title()})",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_role_create(self, role: discord.Role):
|
||||
log_ch = await self._get_log_channel(role.guild, "role_create")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="🏷️ Role Created",
|
||||
description=f"{role.mention} (`{role.name}`)\nColour: `{role.colour}`",
|
||||
colour=role.colour if role.colour.value else discord.Colour(0x2ECC71),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
async def _log_role_delete(self, role: discord.Role):
|
||||
log_ch = await self._get_log_channel(role.guild, "role_delete")
|
||||
if not log_ch:
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="🏷️ Role Deleted",
|
||||
description=f"`{role.name}` · Members: {len(role.members)}",
|
||||
colour=discord.Colour(0xE74C3C),
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
await safe_send(log_ch, embed=embed)
|
||||
self._message_cache = {} # guild_id -> {message_id: MessageData}
|
||||
self._invite_cache = {} # guild_id -> {code: uses}
|
||||
|
||||
async def _cache_invites(self, guild: discord.Guild):
|
||||
try:
|
||||
|
|
@ -424,3 +84,398 @@ class ServerLogMixin:
|
|||
self._invite_cache[guild.id] = {inv.code: inv.uses for inv in invites}
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def _should_log(self, guild: discord.Guild, event_type: str,
|
||||
channel: discord.abc.GuildChannel | None = None,
|
||||
member: discord.Member | None = None) -> bool:
|
||||
data = await self.log_config.guild(guild).all()
|
||||
if not data["enabled"]:
|
||||
return False
|
||||
if not data["enabled_events"].get(event_type, True):
|
||||
return False
|
||||
if channel and channel.id in data["ignore_channels"]:
|
||||
return False
|
||||
if member:
|
||||
if data["ignore_bots"] and member.bot:
|
||||
return False
|
||||
if member.id in data["ignore_users"]:
|
||||
return False
|
||||
if any(r.id in data["ignore_roles"] for r in member.roles):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _get_log_channel(self, guild: discord.Guild, event_type: str) -> discord.TextChannel | None:
|
||||
data = await self.log_config.guild(guild).all()
|
||||
ch_id = data["channels"].get(event_type) or data["default_channel"]
|
||||
if ch_id:
|
||||
return guild.get_channel(ch_id)
|
||||
return None
|
||||
|
||||
async def _log_event(self, guild: discord.Guild, event_type: str,
|
||||
title: str | None = None, description: str = "",
|
||||
fields: list[tuple[str, str, bool]] | None = None,
|
||||
colour: discord.Colour | None = None,
|
||||
thumbnail: str | None = None, author_name: str | None = None,
|
||||
author_icon: str | None = None, footer: str | None = None,
|
||||
channel: discord.abc.GuildChannel | None = None,
|
||||
member: discord.Member | None = None):
|
||||
if not await self._should_log(guild, event_type, channel, member):
|
||||
return
|
||||
log_ch = await self._get_log_channel(guild, event_type)
|
||||
if not log_ch:
|
||||
return
|
||||
|
||||
data = await self.log_config.guild(guild).all()
|
||||
emoji = EVENT_EMOJI.get(event_type, "📋")
|
||||
c = colour or discord.Colour(data.get("embed_colour", Clr.LOG.value))
|
||||
embed = discord.Embed(colour=c, timestamp=datetime.datetime.now(datetime.timezone.utc))
|
||||
if title:
|
||||
embed.title = f"{emoji} {title}"
|
||||
if description:
|
||||
embed.description = description
|
||||
if fields:
|
||||
for name, value, inline in fields:
|
||||
embed.add_field(name=name, value=str(value)[:1024], inline=inline)
|
||||
if thumbnail and data.get("show_avatar"):
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
if author_name:
|
||||
embed.set_author(name=author_name, icon_url=author_icon)
|
||||
if footer:
|
||||
embed.set_footer(text=footer)
|
||||
else:
|
||||
embed.set_footer(text=f"{event_type} · {datetime.datetime.now(datetime.timezone.utc).strftime('%H:%M:%S')}")
|
||||
|
||||
await safe_send(log_ch, embed=embed)
|
||||
|
||||
# ── Message events ─────────────────────────────────────────────────────
|
||||
async def _log_message_delete(self, message: discord.Message):
|
||||
if not message.guild:
|
||||
return
|
||||
# Cache message for logging
|
||||
self._cache_message(message)
|
||||
fields = [
|
||||
("Author", f"{message.author.mention} (`{message.author.id}`)", True),
|
||||
("Channel", message.channel.mention, True),
|
||||
]
|
||||
if message.content:
|
||||
fields.append(("Content", message.content[:1024], False))
|
||||
if message.attachments:
|
||||
fields.append(("Attachments", "\n".join(a.url for a in message.attachments), False))
|
||||
await self._log_event(
|
||||
message.guild, "message_delete", title="Message Deleted",
|
||||
fields=fields, colour=Clr.ERROR,
|
||||
thumbnail=message.author.display_avatar.url if message.author.display_avatar else None,
|
||||
channel=message.channel, member=message.author,
|
||||
)
|
||||
|
||||
async def _log_message_edit(self, before: discord.Message, after: discord.Message):
|
||||
if not after.guild or before.content == after.content:
|
||||
return
|
||||
fields = [
|
||||
("Author", f"{after.author.mention} (`{after.author.id}`)", True),
|
||||
("Channel", after.channel.mention, True),
|
||||
("Before", (before.content or "*empty*")[:1024], False),
|
||||
("After", (after.content or "*empty*")[:1024], False),
|
||||
("Jump", f"[Click]({after.jump_url})", True),
|
||||
]
|
||||
await self._log_event(
|
||||
after.guild, "message_edit", title="Message Edited",
|
||||
fields=fields, colour=discord.Colour(0xF1C40F),
|
||||
channel=after.channel, member=after.author,
|
||||
)
|
||||
|
||||
async def _log_bulk_delete(self, messages: list[discord.Message]):
|
||||
if not messages or not messages[0].guild:
|
||||
return
|
||||
guild = messages[0].guild
|
||||
channel = messages[0].channel
|
||||
desc = f"**{len(messages)}** messages deleted in {channel.mention}"
|
||||
await self._log_event(
|
||||
guild, "message_bulk_delete", title="Bulk Message Delete",
|
||||
description=desc, colour=Clr.ERROR, channel=channel,
|
||||
)
|
||||
|
||||
# ── Member events ──────────────────────────────────────────────────────
|
||||
async def _log_member_join(self, member: discord.Member):
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - member.created_at).days
|
||||
fields = [
|
||||
("User", f"{member.mention} (`{member.id}`)", True),
|
||||
("Account Age", f"{age} days", True),
|
||||
("Member Count", str(member.guild.member_count), True),
|
||||
]
|
||||
# Invite tracking
|
||||
invite_used = await self._detect_invite(member.guild)
|
||||
if invite_used:
|
||||
fields.append(("Invite Used", f"`{invite_used}`", True))
|
||||
await self._log_event(
|
||||
member.guild, "member_join", title="Member Joined",
|
||||
fields=fields, colour=Clr.SUCCESS,
|
||||
thumbnail=member.display_avatar.url if member.display_avatar else None,
|
||||
member=member,
|
||||
)
|
||||
|
||||
async def _log_member_leave(self, member: discord.Member):
|
||||
roles = [r.mention for r in member.roles if r != member.guild.default_role][:20]
|
||||
fields = [
|
||||
("User", f"{member} (`{member.id}`)", True),
|
||||
("Joined", ts_relative(int(member.joined_at.timestamp())) if member.joined_at else "?", True),
|
||||
]
|
||||
if roles:
|
||||
fields.append(("Roles", " ".join(roles), False))
|
||||
await self._log_event(
|
||||
member.guild, "member_leave", title="Member Left",
|
||||
fields=fields, colour=Clr.ERROR,
|
||||
thumbnail=member.display_avatar.url if member.display_avatar else None,
|
||||
)
|
||||
|
||||
async def _log_member_update(self, before: discord.Member, after: discord.Member):
|
||||
# Role changes
|
||||
added = set(after.roles) - set(before.roles)
|
||||
removed = set(before.roles) - set(after.roles)
|
||||
if added or removed:
|
||||
fields = [("User", after.mention, True)]
|
||||
if added:
|
||||
fields.append(("Roles Added", " ".join(r.mention for r in added), False))
|
||||
if removed:
|
||||
fields.append(("Roles Removed", " ".join(r.mention for r in removed), False))
|
||||
await self._log_event(
|
||||
after.guild, "member_update", title="Member Roles Updated",
|
||||
fields=fields, member=after,
|
||||
)
|
||||
|
||||
# Nickname change
|
||||
if before.nick != after.nick:
|
||||
fields = [
|
||||
("User", after.mention, True),
|
||||
("Before", before.nick or before.name, True),
|
||||
("After", after.nick or after.name, True),
|
||||
]
|
||||
await self._log_event(
|
||||
after.guild, "nickname_change", title="Nickname Changed",
|
||||
fields=fields, member=after,
|
||||
)
|
||||
|
||||
# Timeout
|
||||
if before.timed_out_until != after.timed_out_until:
|
||||
if after.timed_out_until and after.timed_out_until > datetime.datetime.now(datetime.timezone.utc):
|
||||
fields = [
|
||||
("User", after.mention, True),
|
||||
("Until", ts_relative(int(after.timed_out_until.timestamp())), True),
|
||||
]
|
||||
await self._log_event(
|
||||
after.guild, "timeout_add", title="Member Timed Out",
|
||||
fields=fields, colour=discord.Colour(0xE74C3C), member=after,
|
||||
)
|
||||
elif before.timed_out_until:
|
||||
await self._log_event(
|
||||
after.guild, "timeout_remove", title="Timeout Removed",
|
||||
description=f"{after.mention} timeout removed.", member=after,
|
||||
)
|
||||
|
||||
async def _log_member_ban(self, guild: discord.Guild, user: discord.User):
|
||||
await self._log_event(
|
||||
guild, "member_ban", title="Member Banned",
|
||||
description=f"{user.mention} (`{user.id}`) was banned.",
|
||||
colour=Clr.ERROR,
|
||||
thumbnail=user.display_avatar.url if user.display_avatar else None,
|
||||
)
|
||||
|
||||
async def _log_member_unban(self, guild: discord.Guild, user: discord.User):
|
||||
await self._log_event(
|
||||
guild, "member_unban", title="Member Unbanned",
|
||||
description=f"{user.mention} (`{user.id}`) was unbanned.",
|
||||
colour=Clr.SUCCESS,
|
||||
)
|
||||
|
||||
# ── Channel events ─────────────────────────────────────────────────────
|
||||
async def _log_channel_create(self, channel: discord.abc.GuildChannel):
|
||||
await self._log_event(
|
||||
channel.guild, "channel_create", title="Channel Created",
|
||||
description=f"{channel.mention} (`{channel.name}`)",
|
||||
fields=[("Type", str(channel.type), True)],
|
||||
colour=Clr.SUCCESS,
|
||||
)
|
||||
|
||||
async def _log_channel_delete(self, channel: discord.abc.GuildChannel):
|
||||
await self._log_event(
|
||||
channel.guild, "channel_delete", title="Channel Deleted",
|
||||
description=f"`{channel.name}` (Type: {channel.type})",
|
||||
colour=Clr.ERROR,
|
||||
)
|
||||
|
||||
async def _log_channel_update(self, before, after):
|
||||
changes = []
|
||||
if hasattr(before, "name") and before.name != after.name:
|
||||
changes.append(f"**Name:** {before.name} → {after.name}")
|
||||
if hasattr(before, "topic") and getattr(before, "topic", None) != getattr(after, "topic", None):
|
||||
changes.append(f"**Topic:** {getattr(before, 'topic', '') or '*empty*'} → {getattr(after, 'topic', '') or '*empty*'}")
|
||||
if hasattr(before, "slowmode_delay") and before.slowmode_delay != after.slowmode_delay:
|
||||
changes.append(f"**Slowmode:** {before.slowmode_delay}s → {after.slowmode_delay}s")
|
||||
if hasattr(before, "nsfw") and before.nsfw != after.nsfw:
|
||||
changes.append(f"**NSFW:** {before.nsfw} → {after.nsfw}")
|
||||
if changes:
|
||||
await self._log_event(
|
||||
after.guild, "channel_update", title="Channel Updated",
|
||||
description=f"{after.mention}\n" + "\n".join(changes),
|
||||
)
|
||||
|
||||
# ── Role events ────────────────────────────────────────────────────────
|
||||
async def _log_role_create(self, role: discord.Role):
|
||||
await self._log_event(
|
||||
role.guild, "role_create", title="Role Created",
|
||||
description=f"{role.mention} (`{role.name}`)",
|
||||
colour=Clr.SUCCESS,
|
||||
)
|
||||
|
||||
async def _log_role_delete(self, role: discord.Role):
|
||||
await self._log_event(
|
||||
role.guild, "role_delete", title="Role Deleted",
|
||||
description=f"`{role.name}` (colour: {role.colour})",
|
||||
colour=Clr.ERROR,
|
||||
)
|
||||
|
||||
async def _log_role_update(self, before: discord.Role, after: discord.Role):
|
||||
changes = []
|
||||
if before.name != after.name:
|
||||
changes.append(f"**Name:** {before.name} → {after.name}")
|
||||
if before.colour != after.colour:
|
||||
changes.append(f"**Colour:** {before.colour} → {after.colour}")
|
||||
if before.hoist != after.hoist:
|
||||
changes.append(f"**Hoisted:** {before.hoist} → {after.hoist}")
|
||||
if before.mentionable != after.mentionable:
|
||||
changes.append(f"**Mentionable:** {before.mentionable} → {after.mentionable}")
|
||||
if before.permissions != after.permissions:
|
||||
added = after.permissions.value & ~before.permissions.value
|
||||
removed = before.permissions.value & ~after.permissions.value
|
||||
if added:
|
||||
changes.append(f"**Permissions Added:** `{discord.Permissions(added).value}`")
|
||||
if removed:
|
||||
changes.append(f"**Permissions Removed:** `{discord.Permissions(removed).value}`")
|
||||
if changes:
|
||||
await self._log_event(
|
||||
after.guild, "role_update", title="Role Updated",
|
||||
description=f"{after.mention}\n" + "\n".join(changes),
|
||||
)
|
||||
|
||||
# ── Voice events ───────────────────────────────────────────────────────
|
||||
async def _log_voice_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
|
||||
if before.channel is None and after.channel is not None:
|
||||
await self._log_event(
|
||||
member.guild, "voice_join", title="Voice Join",
|
||||
description=f"{member.mention} joined **{after.channel.name}**",
|
||||
member=member,
|
||||
)
|
||||
elif before.channel is not None and after.channel is None:
|
||||
await self._log_event(
|
||||
member.guild, "voice_leave", title="Voice Leave",
|
||||
description=f"{member.mention} left **{before.channel.name}**",
|
||||
member=member,
|
||||
)
|
||||
elif before.channel != after.channel:
|
||||
await self._log_event(
|
||||
member.guild, "voice_move", title="Voice Move",
|
||||
description=f"{member.mention}: **{before.channel.name}** → **{after.channel.name}**",
|
||||
member=member,
|
||||
)
|
||||
|
||||
# ── Invite events ──────────────────────────────────────────────────────
|
||||
async def _log_invite_create(self, invite: discord.Invite):
|
||||
if not invite.guild:
|
||||
return
|
||||
await self._cache_invites(invite.guild)
|
||||
await self._log_event(
|
||||
invite.guild, "invite_create", title="Invite Created",
|
||||
fields=[
|
||||
("Code", f"`{invite.code}`", True),
|
||||
("Created by", invite.inviter.mention if invite.inviter else "?", True),
|
||||
("Channel", invite.channel.mention if invite.channel else "?", True),
|
||||
("Max Uses", str(invite.max_uses or "∞"), True),
|
||||
("Max Age", str(invite.max_age or "∞") + "s", True),
|
||||
],
|
||||
)
|
||||
|
||||
async def _log_invite_delete(self, invite: discord.Invite):
|
||||
if not invite.guild:
|
||||
return
|
||||
await self._log_event(
|
||||
invite.guild, "invite_delete", title="Invite Deleted",
|
||||
description=f"`{invite.code}`",
|
||||
colour=Clr.ERROR,
|
||||
)
|
||||
|
||||
# ── Thread events ──────────────────────────────────────────────────────
|
||||
async def _log_thread_create(self, thread: discord.Thread):
|
||||
await self._log_event(
|
||||
thread.guild, "thread_create", title="Thread Created",
|
||||
description=f"{thread.mention} in {thread.parent.mention if thread.parent else '?'}",
|
||||
fields=[("Owner", f"<@{thread.owner_id}>", True)],
|
||||
)
|
||||
|
||||
async def _log_thread_delete(self, thread: discord.Thread):
|
||||
await self._log_event(
|
||||
thread.guild, "thread_delete", title="Thread Deleted",
|
||||
description=f"`{thread.name}`",
|
||||
colour=Clr.ERROR,
|
||||
)
|
||||
|
||||
async def _log_thread_update(self, before: discord.Thread, after: discord.Thread):
|
||||
changes = []
|
||||
if before.name != after.name:
|
||||
changes.append(f"**Name:** {before.name} → {after.name}")
|
||||
if before.archived != after.archived:
|
||||
changes.append(f"**Archived:** {before.archived} → {after.archived}")
|
||||
if before.locked != after.locked:
|
||||
changes.append(f"**Locked:** {before.locked} → {after.locked}")
|
||||
if changes:
|
||||
await self._log_event(
|
||||
after.guild, "thread_update", title="Thread Updated",
|
||||
description=f"{after.mention}\n" + "\n".join(changes),
|
||||
)
|
||||
|
||||
# ── Guild events ───────────────────────────────────────────────────────
|
||||
async def _log_guild_update(self, before: discord.Guild, after: discord.Guild):
|
||||
changes = []
|
||||
if before.name != after.name:
|
||||
changes.append(f"**Name:** {before.name} → {after.name}")
|
||||
if before.icon != after.icon:
|
||||
changes.append("**Icon:** Changed")
|
||||
if before.banner != after.banner:
|
||||
changes.append("**Banner:** Changed")
|
||||
if before.verification_level != after.verification_level:
|
||||
changes.append(f"**Verification:** {before.verification_level} → {after.verification_level}")
|
||||
if changes:
|
||||
await self._log_event(
|
||||
after, "guild_update", title="Server Updated",
|
||||
description="\n".join(changes),
|
||||
)
|
||||
|
||||
# ── Utility ────────────────────────────────────────────────────────────
|
||||
def _cache_message(self, message: discord.Message):
|
||||
if not message.guild:
|
||||
return
|
||||
gid = message.guild.id
|
||||
if gid not in self._message_cache:
|
||||
self._message_cache[gid] = {}
|
||||
self._message_cache[gid][message.id] = {
|
||||
"content": message.content, "author_id": message.author.id,
|
||||
"author_name": str(message.author),
|
||||
"channel_id": message.channel.id, "created_at": message.created_at.isoformat(),
|
||||
}
|
||||
cache_limit = 500
|
||||
if len(self._message_cache[gid]) > cache_limit:
|
||||
oldest = list(self._message_cache[gid].keys())[:cache_limit // 2]
|
||||
for key in oldest:
|
||||
del self._message_cache[gid][key]
|
||||
|
||||
async def _detect_invite(self, guild: discord.Guild) -> str | None:
|
||||
"""Try to detect which invite was used."""
|
||||
try:
|
||||
old = self._invite_cache.get(guild.id, {})
|
||||
new_invites = await guild.invites()
|
||||
self._invite_cache[guild.id] = {inv.code: inv.uses for inv in new_invites}
|
||||
for inv in new_invites:
|
||||
if inv.code in old and inv.uses > old[inv.code]:
|
||||
return inv.code
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""NexusCore — Suggestion system with voting, statuses, threads, anonymous mode."""
|
||||
"""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
|
||||
|
||||
|
|
@ -23,9 +24,8 @@ SUGGEST_DEFAULTS_GUILD = {
|
|||
"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": [],
|
||||
"tags": [], # pre-defined tags
|
||||
"anonymous_allowed": True,
|
||||
"auto_thread": True,
|
||||
"auto_upvote": True,
|
||||
|
|
@ -41,6 +41,10 @@ SUGGEST_DEFAULTS_GUILD = {
|
|||
"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 = {
|
||||
|
|
@ -52,6 +56,8 @@ STATUS_MAP = {
|
|||
"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)),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -78,19 +84,12 @@ class SuggestModal(discord.ui.Modal):
|
|||
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,
|
||||
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,
|
||||
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)
|
||||
|
|
@ -98,15 +97,28 @@ class SuggestModal(discord.ui.Modal):
|
|||
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,
|
||||
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)
|
||||
|
|
@ -117,15 +129,12 @@ class SuggestPanelView(discord.ui.View):
|
|||
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)
|
||||
|
||||
|
|
@ -156,10 +165,7 @@ 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()
|
||||
]
|
||||
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)
|
||||
|
|
@ -171,14 +177,11 @@ class StatusSelectView(discord.ui.View):
|
|||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class SuggestionsMixin:
|
||||
"""Suggestion system mixin."""
|
||||
"""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 = 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)
|
||||
|
||||
|
|
@ -201,18 +204,14 @@ class SuggestionsMixin:
|
|||
emoji, label, colour = STATUS_MAP["pending"]
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Suggestion #{counter}",
|
||||
description=content,
|
||||
colour=colour,
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
|
@ -221,7 +220,6 @@ class SuggestionsMixin:
|
|||
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
|
||||
embed.set_footer(text=f"ID: {s_id}")
|
||||
|
||||
if data["voting_buttons"]:
|
||||
|
|
@ -251,10 +249,14 @@ class SuggestionsMixin:
|
|||
"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
|
||||
|
|
@ -289,11 +291,9 @@ class SuggestionsMixin:
|
|||
s["downvotes"] = downs
|
||||
subs[suggestion_id] = s
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Vote recorded! 👍 {len(ups)} | 👎 {len(downs)}", ephemeral=True
|
||||
)
|
||||
await interaction.response.send_message(f"Vote recorded! 👍 {len(ups)} | 👎 {len(downs)}", ephemeral=True)
|
||||
|
||||
# Update the embed
|
||||
# Update embed
|
||||
data = await conf.all()
|
||||
channel = interaction.guild.get_channel(data["channel"])
|
||||
if channel and s.get("message_id"):
|
||||
|
|
@ -308,14 +308,120 @@ class SuggestionsMixin:
|
|||
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()
|
||||
# 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:
|
||||
|
|
@ -337,11 +443,12 @@ class SuggestionsMixin:
|
|||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(
|
||||
embed=ok_embed(f"Suggestion #{suggestion_id} → **{emoji} {label}**")
|
||||
)
|
||||
try:
|
||||
await interaction.response.send_message(embed=ok_embed(f"Suggestion #{suggestion_id} → **{emoji} {label}**"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Move to status-specific channel if set
|
||||
# Move to status channel if set
|
||||
move_map = {
|
||||
"approved": data.get("approved_channel"),
|
||||
"denied": data.get("denied_channel"),
|
||||
|
|
@ -369,3 +476,26 @@ class SuggestionsMixin:
|
|||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
"""NexusCore — Ticket system with panels, categories, transcripts, claims, priorities, feedback."""
|
||||
"""NexusCore — Ticket system v2: panels, categories, transcripts, claims, priorities,
|
||||
feedback, auto-close, tags, staff tracking, archive, reopen, add/remove user, rename,
|
||||
custom open/close messages, inactivity timer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -12,7 +14,7 @@ from redbot.core import Config, commands
|
|||
|
||||
from .utils import (
|
||||
Clr, ok_embed, err_embed, info_embed, short_id, ts_now, ts_relative,
|
||||
ts_full, duration_str, safe_send, safe_dm, ConfirmView, Paginator, chunk_list,
|
||||
ts_full, duration_str, parse_duration, safe_send, safe_dm, ConfirmView, Paginator, chunk_list,
|
||||
)
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -21,10 +23,12 @@ TICKET_DEFAULTS_GUILD = {
|
|||
"category_id": None,
|
||||
"log_channel": None,
|
||||
"transcript_channel": None,
|
||||
"archive_channel": None,
|
||||
"counter": 0,
|
||||
"panels": {}, # msg_id -> {channel_id, title, description, colour, categories}
|
||||
"categories": {}, # name -> {description, emoji, roles, questions, greeting, priority, channel_name_fmt}
|
||||
"open_tickets": {}, # channel_id -> {user_id, category, opened_at, claimed_by, priority, closed}
|
||||
"panels": {},
|
||||
"categories": {},
|
||||
"open_tickets": {},
|
||||
"closed_tickets": {},
|
||||
"blacklisted": [],
|
||||
"max_per_user": 3,
|
||||
"auto_close_hours": 0,
|
||||
|
|
@ -39,12 +43,20 @@ TICKET_DEFAULTS_GUILD = {
|
|||
"auto_pin_first": True,
|
||||
"allow_rename": True,
|
||||
"allow_user_close": True,
|
||||
"allow_reopen": True,
|
||||
"transcript_format": "html",
|
||||
"custom_open_msg": "",
|
||||
"custom_close_msg": "",
|
||||
"tags": {}, # tag_name -> {colour, description}
|
||||
"staff_roles": [],
|
||||
}
|
||||
|
||||
TICKET_DEFAULTS_MEMBER = {
|
||||
"tickets_opened": 0,
|
||||
"tickets_closed": 0,
|
||||
"feedback_given": [],
|
||||
"avg_response_time": 0,
|
||||
"tickets_claimed": 0,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -72,6 +84,10 @@ async def build_transcript_html(channel: discord.TextChannel, ticket_info: dict)
|
|||
)
|
||||
|
||||
msgs_html = "\n".join(messages)
|
||||
tags = ticket_info.get("tags", [])
|
||||
tags_html = " ".join(f'<span class="tag">{t}</span>' for t in tags) if tags else ""
|
||||
claimed = ticket_info.get("claimed_by")
|
||||
claimed_str = f" · Claimed by: {claimed}" if claimed else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Ticket Transcript</title>
|
||||
<style>
|
||||
|
|
@ -79,6 +95,7 @@ body {{ background: #36393f; color: #dcddde; font-family: 'Segoe UI', sans-serif
|
|||
.header {{ background: #2f3136; padding: 15px; border-radius: 8px; margin-bottom: 20px; }}
|
||||
.header h1 {{ color: #fff; margin: 0; font-size: 1.4em; }}
|
||||
.header p {{ color: #72767d; margin: 4px 0 0; }}
|
||||
.tag {{ background: #5865f2; color: #fff; padding: 2px 8px; border-radius: 4px; margin-right: 4px; font-size: 0.85em; }}
|
||||
.msg {{ display: flex; padding: 8px 16px; }}
|
||||
.msg:hover {{ background: #32353b; }}
|
||||
.avatar {{ width: 40px; height: 40px; border-radius: 50%; margin-right: 12px; flex-shrink: 0; }}
|
||||
|
|
@ -90,7 +107,8 @@ a {{ color: #00aff4; }}
|
|||
</style></head><body>
|
||||
<div class="header">
|
||||
<h1>Ticket Transcript — #{ticket_info.get("number", "?")}</h1>
|
||||
<p>Category: {ticket_info.get("category", "General")} · Opened by: {ticket_info.get("user_name", "Unknown")} · {ticket_info.get("opened_at_str", "")}</p>
|
||||
<p>Category: {ticket_info.get("category", "General")} · Opened by: {ticket_info.get("user_name", "Unknown")} · {ticket_info.get("opened_at_str", "")}{claimed_str}</p>
|
||||
{tags_html}
|
||||
</div>
|
||||
{msgs_html}
|
||||
</body></html>"""
|
||||
|
|
@ -98,8 +116,6 @@ a {{ color: #00aff4; }}
|
|||
|
||||
# ── Views ──────────────────────────────────────────────────────────────────
|
||||
class TicketPanelView(discord.ui.View):
|
||||
"""Persistent panel with a select menu for category or a single create button."""
|
||||
|
||||
def __init__(self, cog: "TicketsMixin"):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
|
|
@ -157,8 +173,7 @@ class CategorySelectView(discord.ui.View):
|
|||
for name in cat_names[:25]:
|
||||
cd = cats_data.get(name, {})
|
||||
options.append(discord.SelectOption(
|
||||
label=name.title(),
|
||||
value=name,
|
||||
label=name.title(), value=name,
|
||||
description=(cd.get("description", "") or "")[:100],
|
||||
emoji=cd.get("emoji") or "🎫",
|
||||
))
|
||||
|
|
@ -191,8 +206,7 @@ class TicketFormModal(discord.ui.Modal):
|
|||
inp = discord.ui.TextInput(
|
||||
label=q[:45],
|
||||
style=discord.TextStyle.paragraph if len(q) > 30 else discord.TextStyle.short,
|
||||
required=True,
|
||||
max_length=1024,
|
||||
required=True, max_length=1024,
|
||||
)
|
||||
self.inputs.append(inp)
|
||||
self.add_item(inp)
|
||||
|
|
@ -206,8 +220,6 @@ class TicketFormModal(discord.ui.Modal):
|
|||
|
||||
|
||||
class TicketControlView(discord.ui.View):
|
||||
"""Controls inside a ticket channel: close, claim, priority."""
|
||||
|
||||
def __init__(self, cog: "TicketsMixin"):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
|
|
@ -225,6 +237,15 @@ class TicketControlView(discord.ui.View):
|
|||
view = PrioritySelectView(self.cog)
|
||||
await interaction.response.send_message("Set priority:", view=view, ephemeral=True)
|
||||
|
||||
@discord.ui.button(label="Tag", style=discord.ButtonStyle.secondary, emoji="🏷️", custom_id="nexus_ticket_tag")
|
||||
async def tag_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
data = await self.cog.ticket_config.guild(interaction.guild).all()
|
||||
tags = data.get("tags", {})
|
||||
if not tags:
|
||||
return await interaction.response.send_message("No tags configured.", ephemeral=True)
|
||||
view = TagSelectView(self.cog, tags)
|
||||
await interaction.response.send_message("Apply a tag:", view=view, ephemeral=True)
|
||||
|
||||
|
||||
class PrioritySelectView(discord.ui.View):
|
||||
def __init__(self, cog):
|
||||
|
|
@ -247,9 +268,28 @@ class PrioritySelectView(discord.ui.View):
|
|||
if ch_id in tickets:
|
||||
tickets[ch_id]["priority"] = prio
|
||||
prio_emoji = {"low": "🟢", "medium": "🟡", "high": "🟠", "urgent": "🔴"}
|
||||
await interaction.response.send_message(
|
||||
f"{prio_emoji.get(prio, '⚡')} Priority set to **{prio.title()}**", ephemeral=False
|
||||
)
|
||||
await interaction.response.send_message(f"{prio_emoji.get(prio, '⚡')} Priority set to **{prio.title()}**")
|
||||
self.stop()
|
||||
|
||||
|
||||
class TagSelectView(discord.ui.View):
|
||||
def __init__(self, cog, tags: dict):
|
||||
super().__init__(timeout=30)
|
||||
self.cog = cog
|
||||
opts = [discord.SelectOption(label=name.title(), value=name, description=(t.get("description", "") or "")[:100]) for name, t in list(tags.items())[:25]]
|
||||
self.sel = discord.ui.Select(options=opts, placeholder="Tag...")
|
||||
self.sel.callback = self.on_select
|
||||
self.add_item(self.sel)
|
||||
|
||||
async def on_select(self, interaction: discord.Interaction):
|
||||
tag = self.sel.values[0]
|
||||
ch_id = str(interaction.channel.id)
|
||||
async with self.cog.ticket_config.guild(interaction.guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id].setdefault("tags", [])
|
||||
if tag not in tickets[ch_id]["tags"]:
|
||||
tickets[ch_id]["tags"].append(tag)
|
||||
await interaction.response.send_message(f"🏷️ Tag **{tag}** applied!")
|
||||
self.stop()
|
||||
|
||||
|
||||
|
|
@ -258,19 +298,14 @@ class FeedbackModal(discord.ui.Modal):
|
|||
super().__init__(title="Ticket Feedback")
|
||||
self.cog = cog
|
||||
self.channel_id = channel_id
|
||||
self.rating = discord.ui.TextInput(
|
||||
label="Rating (1-5 stars)", placeholder="5", max_length=1, required=True
|
||||
)
|
||||
self.comments = discord.ui.TextInput(
|
||||
label="Comments (optional)", style=discord.TextStyle.paragraph, required=False, max_length=500
|
||||
)
|
||||
self.rating = discord.ui.TextInput(label="Rating (1-5 stars)", placeholder="5", max_length=1, required=True)
|
||||
self.comments = discord.ui.TextInput(label="Comments (optional)", style=discord.TextStyle.paragraph, required=False, max_length=500)
|
||||
self.add_item(self.rating)
|
||||
self.add_item(self.comments)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
try:
|
||||
r = int(self.rating.value)
|
||||
r = max(1, min(5, r))
|
||||
r = max(1, min(5, int(self.rating.value)))
|
||||
except ValueError:
|
||||
r = 5
|
||||
stars = "⭐" * r
|
||||
|
|
@ -287,14 +322,25 @@ class FeedbackModal(discord.ui.Modal):
|
|||
await safe_send(log_ch, embed=e)
|
||||
|
||||
|
||||
class FeedbackButtonView(discord.ui.View):
|
||||
def __init__(self, cog, channel_id: str):
|
||||
super().__init__(timeout=3600)
|
||||
self.cog = cog
|
||||
self.channel_id = channel_id
|
||||
|
||||
@discord.ui.button(label="Leave Feedback", style=discord.ButtonStyle.primary, emoji="📝")
|
||||
async def feedback_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
modal = FeedbackModal(self.cog, self.channel_id)
|
||||
await interaction.response.send_modal(modal)
|
||||
self.stop()
|
||||
|
||||
|
||||
# ── Mixin ──────────────────────────────────────────────────────────────────
|
||||
class TicketsMixin:
|
||||
"""Ticket system mixin — mixed into the main NexusCore cog."""
|
||||
"""Ticket system mixin — v2 with auto-close, tags, staff tracking, archive, reopen."""
|
||||
|
||||
def _init_tickets(self, bot):
|
||||
self.ticket_config = Config.get_conf(
|
||||
None, identifier=900001, cog_name="NexusCoreTickets"
|
||||
)
|
||||
self.ticket_config = Config.get_conf(None, identifier=900001, cog_name="NexusCoreTickets")
|
||||
self.ticket_config.register_guild(**TICKET_DEFAULTS_GUILD)
|
||||
self.ticket_config.register_member(**TICKET_DEFAULTS_MEMBER)
|
||||
|
||||
|
|
@ -302,14 +348,76 @@ class TicketsMixin:
|
|||
self._ticket_control_view = TicketControlView(self)
|
||||
bot.add_view(self._ticket_panel_view)
|
||||
bot.add_view(self._ticket_control_view)
|
||||
self._auto_close_tasks = {}
|
||||
|
||||
async def _start_auto_close_loop(self):
|
||||
"""Check for inactive tickets to auto-close."""
|
||||
while True:
|
||||
try:
|
||||
for guild in self.bot.guilds:
|
||||
data = await self.ticket_config.guild(guild).all()
|
||||
hours = data.get("auto_close_hours", 0)
|
||||
if not hours or not data["enabled"]:
|
||||
continue
|
||||
threshold = ts_now() - (hours * 3600)
|
||||
for ch_id, ticket in list(data["open_tickets"].items()):
|
||||
if ticket.get("closed"):
|
||||
continue
|
||||
last_activity = ticket.get("last_activity", ticket.get("opened_at", 0))
|
||||
if last_activity < threshold:
|
||||
channel = guild.get_channel(int(ch_id))
|
||||
if channel:
|
||||
await channel.send(embed=discord.Embed(
|
||||
description=f"⏰ This ticket will be auto-closed due to {hours}h of inactivity.\nSend a message to keep it open.",
|
||||
colour=Clr.TICKET,
|
||||
))
|
||||
# Give 5 min grace
|
||||
await asyncio.sleep(300)
|
||||
# Re-check
|
||||
fresh = await self.ticket_config.guild(guild).open_tickets()
|
||||
t = fresh.get(ch_id)
|
||||
if t and not t.get("closed"):
|
||||
la = t.get("last_activity", t.get("opened_at", 0))
|
||||
if la < threshold:
|
||||
async with self.ticket_config.guild(guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id]["closed"] = True
|
||||
tickets[ch_id]["closed_at"] = ts_now()
|
||||
tickets[ch_id]["closed_by"] = "auto"
|
||||
await channel.send(embed=discord.Embed(
|
||||
description="🔒 Auto-closed due to inactivity. Deleting in 30s...",
|
||||
colour=Clr.ERROR,
|
||||
))
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await channel.delete(reason="Auto-close inactivity")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(600) # Check every 10 min
|
||||
|
||||
async def _update_ticket_activity(self, message: discord.Message):
|
||||
"""Update last activity timestamp for a ticket channel."""
|
||||
if not message.guild:
|
||||
return
|
||||
ch_id = str(message.channel.id)
|
||||
data = await self.ticket_config.guild(message.guild).open_tickets()
|
||||
if ch_id in data and not data[ch_id].get("closed"):
|
||||
async with self.ticket_config.guild(message.guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id]["last_activity"] = ts_now()
|
||||
# Track first staff response time
|
||||
if not tickets[ch_id].get("first_response_at"):
|
||||
staff_roles = await self.ticket_config.guild(message.guild).staff_roles()
|
||||
is_staff = message.author.guild_permissions.manage_channels or any(r.id in staff_roles for r in message.author.roles)
|
||||
if is_staff and message.author.id != tickets[ch_id].get("user_id"):
|
||||
tickets[ch_id]["first_response_at"] = ts_now()
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────
|
||||
async def _create_ticket_channel(
|
||||
self, interaction: discord.Interaction, category_name: str | None, answers: dict
|
||||
) -> discord.TextChannel | None:
|
||||
async def _create_ticket_channel(self, interaction, category_name, answers):
|
||||
guild = interaction.guild
|
||||
conf = self.ticket_config.guild(guild)
|
||||
|
||||
counter = await conf.counter()
|
||||
counter += 1
|
||||
await conf.counter.set(counter)
|
||||
|
|
@ -321,9 +429,7 @@ class TicketsMixin:
|
|||
|
||||
overwrites = {
|
||||
guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
||||
interaction.user: discord.PermissionOverwrite(
|
||||
view_channel=True, send_messages=True, attach_files=True, embed_links=True
|
||||
),
|
||||
interaction.user: discord.PermissionOverwrite(view_channel=True, send_messages=True, attach_files=True, embed_links=True),
|
||||
guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True),
|
||||
}
|
||||
for role_id in cat_data.get("roles", []):
|
||||
|
|
@ -332,49 +438,39 @@ class TicketsMixin:
|
|||
overwrites[role] = discord.PermissionOverwrite(view_channel=True, send_messages=True)
|
||||
|
||||
disc_cat = guild.get_channel(data["category_id"]) if data["category_id"] else None
|
||||
|
||||
try:
|
||||
channel = await guild.create_text_channel(
|
||||
ch_name,
|
||||
category=disc_cat,
|
||||
overwrites=overwrites,
|
||||
topic=f"Ticket #{counter} · {interaction.user} · {category_name or 'General'}",
|
||||
)
|
||||
channel = await guild.create_text_channel(ch_name, category=disc_cat, overwrites=overwrites,
|
||||
topic=f"Ticket #{counter} · {interaction.user} · {category_name or 'General'}")
|
||||
except discord.HTTPException:
|
||||
return None
|
||||
|
||||
ticket_info = {
|
||||
"user_id": interaction.user.id,
|
||||
"user_name": str(interaction.user),
|
||||
"user_id": interaction.user.id, "user_name": str(interaction.user),
|
||||
"category": category_name or "general",
|
||||
"opened_at": ts_now(),
|
||||
"opened_at_str": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M"),
|
||||
"number": counter,
|
||||
"claimed_by": None,
|
||||
"number": counter, "claimed_by": None,
|
||||
"priority": cat_data.get("default_priority", "medium"),
|
||||
"closed": False,
|
||||
"answers": answers,
|
||||
"closed": False, "answers": answers, "tags": [],
|
||||
"last_activity": ts_now(), "first_response_at": None,
|
||||
"participants": [interaction.user.id],
|
||||
}
|
||||
async with conf.open_tickets() as tickets:
|
||||
tickets[str(channel.id)] = ticket_info
|
||||
|
||||
greeting = cat_data.get("greeting") or f"Welcome, {interaction.user.mention}! A staff member will be with you shortly."
|
||||
prio_emoji = {"low": "🟢", "medium": "🟡", "high": "🟠", "urgent": "🔴"}.get(ticket_info["priority"], "🟡")
|
||||
# Custom or default greeting
|
||||
greeting = data.get("custom_open_msg") or cat_data.get("greeting") or f"Welcome, {interaction.user.mention}! A staff member will be with you shortly."
|
||||
greeting = greeting.replace("{user}", interaction.user.mention).replace("{category}", category_name or "General").replace("{ticket_id}", str(counter))
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"🎫 Ticket #{counter}",
|
||||
description=greeting,
|
||||
colour=Clr.TICKET,
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
prio_emoji = {"low": "🟢", "medium": "🟡", "high": "🟠", "urgent": "🔴"}.get(ticket_info["priority"], "🟡")
|
||||
embed = discord.Embed(title=f"🎫 Ticket #{counter}", description=greeting, colour=Clr.TICKET,
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc))
|
||||
embed.add_field(name="Category", value=category_name or "General", inline=True)
|
||||
embed.add_field(name="Priority", value=f"{prio_emoji} {ticket_info['priority'].title()}", inline=True)
|
||||
embed.add_field(name="Opened by", value=interaction.user.mention, inline=True)
|
||||
|
||||
if answers:
|
||||
for q, a in answers.items():
|
||||
embed.add_field(name=q, value=a[:1024], inline=False)
|
||||
|
||||
embed.set_footer(text="Use the buttons below to manage this ticket")
|
||||
|
||||
msg = await channel.send(embed=embed, view=self._ticket_control_view)
|
||||
|
|
@ -399,6 +495,8 @@ class TicketsMixin:
|
|||
colour=Clr.TICKET,
|
||||
))
|
||||
|
||||
await self._ticket_member_update(guild, interaction.user, "tickets_opened", 1)
|
||||
|
||||
log_ch_id = data["log_channel"]
|
||||
if log_ch_id:
|
||||
log_ch = guild.get_channel(log_ch_id)
|
||||
|
|
@ -411,7 +509,11 @@ class TicketsMixin:
|
|||
|
||||
return channel
|
||||
|
||||
async def _close_ticket(self, interaction: discord.Interaction):
|
||||
async def _ticket_member_update(self, guild, member, key, increment):
|
||||
current = await getattr(self.ticket_config.member(member), key)()
|
||||
await getattr(self.ticket_config.member(member), key).set(current + increment)
|
||||
|
||||
async def _close_ticket(self, interaction):
|
||||
guild = interaction.guild
|
||||
ch_id = str(interaction.channel.id)
|
||||
data = await self.ticket_config.guild(guild).all()
|
||||
|
|
@ -420,7 +522,6 @@ class TicketsMixin:
|
|||
return await interaction.response.send_message("This is not a ticket channel.", ephemeral=True)
|
||||
if ticket.get("closed"):
|
||||
return await interaction.response.send_message("Already closed.", ephemeral=True)
|
||||
|
||||
if not data["allow_user_close"] and interaction.user.id == ticket["user_id"]:
|
||||
if not interaction.user.guild_permissions.manage_channels:
|
||||
return await interaction.response.send_message("Only staff can close tickets.", ephemeral=True)
|
||||
|
|
@ -440,26 +541,34 @@ class TicketsMixin:
|
|||
tickets[ch_id]["closed_at"] = ts_now()
|
||||
tickets[ch_id]["closed_by"] = interaction.user.id
|
||||
|
||||
# Archive to closed_tickets for stats
|
||||
async with self.ticket_config.guild(guild).closed_tickets() as closed:
|
||||
closed[ch_id] = dict(ticket)
|
||||
closed[ch_id]["closed_at"] = ts_now()
|
||||
closed[ch_id]["closed_by"] = interaction.user.id
|
||||
|
||||
# Transcript
|
||||
transcript_ch_id = data.get("transcript_channel")
|
||||
if transcript_ch_id:
|
||||
transcript_ch = guild.get_channel(transcript_ch_id)
|
||||
if transcript_ch:
|
||||
html = await build_transcript_html(interaction.channel, ticket)
|
||||
file = discord.File(
|
||||
io.BytesIO(html.encode()),
|
||||
filename=f"transcript-{ticket.get('number', 0)}.html"
|
||||
)
|
||||
te = discord.Embed(
|
||||
title=f"📄 Transcript — Ticket #{ticket.get('number', 0)}",
|
||||
colour=Clr.TICKET,
|
||||
)
|
||||
file = discord.File(io.BytesIO(html.encode()), filename=f"transcript-{ticket.get('number', 0)}.html")
|
||||
te = discord.Embed(title=f"📄 Transcript — Ticket #{ticket.get('number', 0)}", colour=Clr.TICKET)
|
||||
te.add_field(name="User", value=f"<@{ticket['user_id']}>", inline=True)
|
||||
te.add_field(name="Closed by", value=interaction.user.mention, inline=True)
|
||||
te.add_field(name="Category", value=ticket.get("category", "General"), inline=True)
|
||||
if ticket.get("tags"):
|
||||
te.add_field(name="Tags", value=", ".join(ticket["tags"]), inline=True)
|
||||
if ticket.get("first_response_at"):
|
||||
response_time = ticket["first_response_at"] - ticket["opened_at"]
|
||||
te.add_field(name="First Response", value=duration_str(response_time), inline=True)
|
||||
await safe_send(transcript_ch, embed=te, file=file)
|
||||
|
||||
# Custom close message
|
||||
close_msg = data.get("custom_close_msg") or "🔒 This ticket has been closed."
|
||||
if data["dm_on_close"]:
|
||||
user = guild.get_member(ticket["user_id"]) or await guild.fetch_member(ticket["user_id"])
|
||||
user = guild.get_member(ticket["user_id"])
|
||||
if user:
|
||||
await safe_dm(user, embed=discord.Embed(
|
||||
description=f"Your ticket **#{ticket.get('number', 0)}** in **{guild.name}** has been closed.",
|
||||
|
|
@ -469,12 +578,8 @@ class TicketsMixin:
|
|||
if data["feedback_enabled"]:
|
||||
user = guild.get_member(ticket["user_id"])
|
||||
if user:
|
||||
modal = FeedbackModal(self, ch_id)
|
||||
try:
|
||||
await user.send(
|
||||
"Please rate your support experience!",
|
||||
view=FeedbackButtonView(self, ch_id)
|
||||
)
|
||||
await user.send("Please rate your support experience!", view=FeedbackButtonView(self, ch_id))
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
|
@ -487,17 +592,30 @@ class TicketsMixin:
|
|||
le.add_field(name="Closed by", value=interaction.user.mention, inline=True)
|
||||
await safe_send(log_ch, embed=le)
|
||||
|
||||
await interaction.channel.send(embed=discord.Embed(
|
||||
description="🔒 This ticket has been closed. Deleting in 10 seconds...",
|
||||
colour=Clr.ERROR,
|
||||
))
|
||||
# Archive channel option (move instead of delete)
|
||||
archive_ch_id = data.get("archive_channel")
|
||||
if archive_ch_id:
|
||||
archive_cat = guild.get_channel(archive_ch_id)
|
||||
if archive_cat and isinstance(archive_cat, discord.CategoryChannel):
|
||||
try:
|
||||
await interaction.channel.edit(
|
||||
category=archive_cat,
|
||||
overwrites={guild.default_role: discord.PermissionOverwrite(view_channel=False, send_messages=False)},
|
||||
reason="Ticket archived",
|
||||
)
|
||||
await interaction.channel.send(embed=discord.Embed(description=close_msg, colour=Clr.ERROR))
|
||||
return # Don't delete if archiving
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await interaction.channel.send(embed=discord.Embed(description=f"{close_msg}\nDeleting in 10 seconds...", colour=Clr.ERROR))
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
await interaction.channel.delete(reason=f"Ticket #{ticket.get('number', 0)} closed")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def _claim_ticket(self, interaction: discord.Interaction):
|
||||
async def _claim_ticket(self, interaction):
|
||||
guild = interaction.guild
|
||||
ch_id = str(interaction.channel.id)
|
||||
data = await self.ticket_config.guild(guild).all()
|
||||
|
|
@ -507,30 +625,102 @@ class TicketsMixin:
|
|||
if not data["claim_enabled"]:
|
||||
return await interaction.response.send_message("Claim system is disabled.", ephemeral=True)
|
||||
if ticket.get("claimed_by"):
|
||||
return await interaction.response.send_message(
|
||||
f"Already claimed by <@{ticket['claimed_by']}>.", ephemeral=True
|
||||
)
|
||||
return await interaction.response.send_message(f"Already claimed by <@{ticket['claimed_by']}>.", ephemeral=True)
|
||||
|
||||
async with self.ticket_config.guild(guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id]["claimed_by"] = interaction.user.id
|
||||
await self._ticket_member_update(guild, interaction.user, "tickets_claimed", 1)
|
||||
|
||||
await interaction.response.send_message(
|
||||
embed=discord.Embed(
|
||||
description=f"🙋 **{interaction.user.display_name}** claimed this ticket.",
|
||||
colour=Clr.SUCCESS,
|
||||
)
|
||||
)
|
||||
await interaction.response.send_message(embed=discord.Embed(
|
||||
description=f"🙋 **{interaction.user.display_name}** claimed this ticket.", colour=Clr.SUCCESS,
|
||||
))
|
||||
|
||||
async def _reopen_ticket(self, ctx, channel: discord.TextChannel):
|
||||
"""Reopen a closed/archived ticket."""
|
||||
ch_id = str(channel.id)
|
||||
data = await self.ticket_config.guild(ctx.guild).all()
|
||||
ticket = data["open_tickets"].get(ch_id)
|
||||
if not ticket or not ticket.get("closed"):
|
||||
return False
|
||||
if not data.get("allow_reopen"):
|
||||
return False
|
||||
|
||||
class FeedbackButtonView(discord.ui.View):
|
||||
def __init__(self, cog, channel_id: str):
|
||||
super().__init__(timeout=3600)
|
||||
self.cog = cog
|
||||
self.channel_id = channel_id
|
||||
async with self.ticket_config.guild(ctx.guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id]["closed"] = False
|
||||
tickets[ch_id].pop("closed_at", None)
|
||||
tickets[ch_id].pop("closed_by", None)
|
||||
tickets[ch_id]["last_activity"] = ts_now()
|
||||
|
||||
@discord.ui.button(label="Leave Feedback", style=discord.ButtonStyle.primary, emoji="📝")
|
||||
async def feedback_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
modal = FeedbackModal(self.cog, self.channel_id)
|
||||
await interaction.response.send_modal(modal)
|
||||
self.stop()
|
||||
# Restore permissions
|
||||
user = ctx.guild.get_member(ticket["user_id"])
|
||||
if user:
|
||||
await channel.set_permissions(user, view_channel=True, send_messages=True)
|
||||
|
||||
await channel.send(embed=discord.Embed(
|
||||
description=f"🔓 Ticket reopened by {ctx.author.mention}.", colour=Clr.SUCCESS,
|
||||
))
|
||||
return True
|
||||
|
||||
async def _add_user_to_ticket(self, ctx, user: discord.Member):
|
||||
"""Add a user to the current ticket channel."""
|
||||
ch_id = str(ctx.channel.id)
|
||||
data = await self.ticket_config.guild(ctx.guild).open_tickets()
|
||||
if ch_id not in data:
|
||||
return False
|
||||
await ctx.channel.set_permissions(user, view_channel=True, send_messages=True, attach_files=True)
|
||||
async with self.ticket_config.guild(ctx.guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
tickets[ch_id].setdefault("participants", []).append(user.id)
|
||||
await ctx.channel.send(embed=discord.Embed(description=f"➕ {user.mention} added to this ticket.", colour=Clr.SUCCESS))
|
||||
return True
|
||||
|
||||
async def _remove_user_from_ticket(self, ctx, user: discord.Member):
|
||||
"""Remove a user from the current ticket channel."""
|
||||
ch_id = str(ctx.channel.id)
|
||||
data = await self.ticket_config.guild(ctx.guild).open_tickets()
|
||||
if ch_id not in data:
|
||||
return False
|
||||
await ctx.channel.set_permissions(user, overwrite=None)
|
||||
async with self.ticket_config.guild(ctx.guild).open_tickets() as tickets:
|
||||
if ch_id in tickets:
|
||||
p = tickets[ch_id].get("participants", [])
|
||||
if user.id in p:
|
||||
p.remove(user.id)
|
||||
await ctx.channel.send(embed=discord.Embed(description=f"➖ {user.mention} removed from this ticket.", colour=Clr.ERROR))
|
||||
return True
|
||||
|
||||
async def _rename_ticket(self, ctx, new_name: str):
|
||||
"""Rename the current ticket channel."""
|
||||
ch_id = str(ctx.channel.id)
|
||||
data = await self.ticket_config.guild(ctx.guild).open_tickets()
|
||||
if ch_id not in data:
|
||||
return False
|
||||
if not data[ch_id].get("closed") and await self.ticket_config.guild(ctx.guild).allow_rename():
|
||||
await ctx.channel.edit(name=new_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_ticket_stats(self, closed_tickets: dict) -> dict:
|
||||
"""Compute aggregate ticket stats."""
|
||||
total = len(closed_tickets)
|
||||
if not total:
|
||||
return {"total": 0}
|
||||
categories = {}
|
||||
total_response = 0
|
||||
response_count = 0
|
||||
for t in closed_tickets.values():
|
||||
cat = t.get("category", "general")
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
frt = t.get("first_response_at")
|
||||
opened = t.get("opened_at", 0)
|
||||
if frt and opened:
|
||||
total_response += frt - opened
|
||||
response_count += 1
|
||||
avg_response = total_response // max(response_count, 1)
|
||||
return {
|
||||
"total": total,
|
||||
"categories": categories,
|
||||
"avg_first_response": duration_str(avg_response) if avg_response else "N/A",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue