POJ mentions new human members in configured channels then auto-deletes the ping after a configurable delay. The affiliate board is a staff-managed, persistent plain-text listing (modal to add, select+confirm to remove), 10 entries per message with a new message per additional page, always kept in sync in real time and recreated if a page is ever deleted. DM Affiliates mirrors the same management on a fully separate list sent to new members via DM on join. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
7.5 KiB
Python
191 lines
7.5 KiB
Python
"""
|
||
Interactive components for the Affiliates cog: the "add" modal (and the
|
||
button that opens it, since modals can only be opened in response to a
|
||
component interaction, never a bare prefix command), and the
|
||
select-then-confirm flow used to remove an entry.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import List
|
||
|
||
import discord
|
||
from discord import ui
|
||
|
||
COLOUR_OK = discord.Colour.green()
|
||
COLOUR_INFO = discord.Colour.blurple()
|
||
COLOUR_ERR = discord.Colour.red()
|
||
|
||
|
||
def ok_embed(text: str) -> discord.Embed:
|
||
return discord.Embed(description=text, colour=COLOUR_OK)
|
||
|
||
|
||
def info_embed(text: str) -> discord.Embed:
|
||
return discord.Embed(description=text, colour=COLOUR_INFO)
|
||
|
||
|
||
def err_embed(text: str) -> discord.Embed:
|
||
return discord.Embed(description=text, colour=COLOUR_ERR)
|
||
|
||
|
||
class AffiliateModal(ui.Modal):
|
||
# Capped tightly (not just for tidiness): the board renders 10 entries per
|
||
# plain-text message, and Discord caps message content at 2000 characters.
|
||
# These max_lengths keep a worst-case 10-entry page under that cap with
|
||
# margin to spare — see the render_pages()/`_sync_aff_message` docstring.
|
||
server_name = ui.TextInput(
|
||
label="Server Name",
|
||
placeholder="e.g. Ruby's Hangout",
|
||
max_length=60,
|
||
style=discord.TextStyle.short,
|
||
)
|
||
server_invite = ui.TextInput(
|
||
label="Server Invite",
|
||
placeholder="e.g. https://discord.gg/abc123",
|
||
max_length=100,
|
||
style=discord.TextStyle.short,
|
||
)
|
||
|
||
def __init__(self, cog, guild_id: int, list_kind: str):
|
||
super().__init__(title="➕ Add Affiliate" if list_kind == "aff" else "➕ Add DM Affiliate")
|
||
self.cog = cog
|
||
self.guild_id = guild_id
|
||
self.list_kind = list_kind # "aff" or "dm"
|
||
|
||
async def on_submit(self, interaction: discord.Interaction) -> None:
|
||
name = str(self.server_name).strip()
|
||
invite = str(self.server_invite).strip()
|
||
if not name or not invite:
|
||
return await interaction.response.send_message(
|
||
embed=err_embed("Both fields are required."), ephemeral=True
|
||
)
|
||
|
||
position = await self.cog.add_entry(self.guild_id, self.list_kind, name, invite, interaction.user.id)
|
||
if position is None:
|
||
return await interaction.response.send_message(
|
||
embed=err_embed("That list is full — it already holds the maximum of 100 entries."),
|
||
ephemeral=True,
|
||
)
|
||
|
||
label = "Affiliate" if self.list_kind == "aff" else "DM Affiliate"
|
||
await interaction.response.send_message(
|
||
embed=ok_embed(f"✅ **{name}** added as {label} **#{position}**."), ephemeral=True
|
||
)
|
||
|
||
|
||
class AddAffiliateButtonView(ui.View):
|
||
"""Sent in response to `aff add`/`aff dm add` — clicking it is the component
|
||
interaction needed to actually open the modal."""
|
||
|
||
def __init__(self, cog, guild_id: int, list_kind: str, author_id: int):
|
||
super().__init__(timeout=180)
|
||
self.cog = cog
|
||
self.guild_id = guild_id
|
||
self.list_kind = list_kind
|
||
self.author_id = author_id
|
||
|
||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||
if interaction.user.id != self.author_id:
|
||
await interaction.response.send_message(
|
||
"Not your prompt — run the command yourself.", ephemeral=True
|
||
)
|
||
return False
|
||
return True
|
||
|
||
@ui.button(label="Add Affiliate", style=discord.ButtonStyle.success, emoji="➕")
|
||
async def add_btn(self, interaction: discord.Interaction, button: ui.Button) -> None:
|
||
await interaction.response.send_modal(AffiliateModal(self.cog, self.guild_id, self.list_kind))
|
||
|
||
|
||
class RemoveConfirmView(ui.View):
|
||
def __init__(self, cog, guild_id: int, list_kind: str, author_id: int, entry_id: int, entry_name: str):
|
||
super().__init__(timeout=60)
|
||
self.cog = cog
|
||
self.guild_id = guild_id
|
||
self.list_kind = list_kind
|
||
self.author_id = author_id
|
||
self.entry_id = entry_id
|
||
self.entry_name = entry_name
|
||
|
||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||
if interaction.user.id != self.author_id:
|
||
await interaction.response.send_message("Not your confirmation.", ephemeral=True)
|
||
return False
|
||
return True
|
||
|
||
@ui.button(label="Remove", style=discord.ButtonStyle.danger, emoji="🗑️")
|
||
async def confirm(self, interaction: discord.Interaction, button: ui.Button) -> None:
|
||
removed = await self.cog.remove_entry(self.guild_id, self.list_kind, self.entry_id)
|
||
if removed:
|
||
await interaction.response.edit_message(
|
||
embed=ok_embed(f"✅ Removed **{self.entry_name}**."), view=None
|
||
)
|
||
else:
|
||
await interaction.response.edit_message(
|
||
embed=err_embed("That entry was already removed."), view=None
|
||
)
|
||
self.stop()
|
||
|
||
@ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
|
||
async def cancel(self, interaction: discord.Interaction, button: ui.Button) -> None:
|
||
await interaction.response.edit_message(embed=info_embed("Cancelled — nothing was removed."), view=None)
|
||
self.stop()
|
||
|
||
|
||
class RemoveSelectView(ui.View):
|
||
"""Sent in response to `aff remove`/`aff dm remove` — populates a Select
|
||
from the current entries; picking one swaps this message to a confirm step."""
|
||
|
||
def __init__(
|
||
self, cog, guild_id: int, list_kind: str, author_id: int, entries: List[dict], page: int = 1
|
||
):
|
||
super().__init__(timeout=180)
|
||
self.cog = cog
|
||
self.guild_id = guild_id
|
||
self.list_kind = list_kind
|
||
self.author_id = author_id
|
||
|
||
# Discord select menus cap at 25 options, but the list can hold up to
|
||
# 100 — slice per page while keeping each label's true global position
|
||
# (not just its position within this page).
|
||
start = (page - 1) * 25
|
||
page_entries = entries[start:start + 25]
|
||
options = [
|
||
discord.SelectOption(label=f"#{start + i + 1} — {e['name'][:80]}", value=str(e["id"]))
|
||
for i, e in enumerate(page_entries)
|
||
]
|
||
placeholder = "Choose an entry to remove..."
|
||
total_pages = (len(entries) + 24) // 25
|
||
if total_pages > 1:
|
||
placeholder = f"Choose an entry to remove... (page {page}/{total_pages})"
|
||
self.select = ui.Select(placeholder=placeholder, options=options)
|
||
self.select.callback = self._on_select
|
||
self.add_item(self.select)
|
||
|
||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||
if interaction.user.id != self.author_id:
|
||
await interaction.response.send_message(
|
||
"Not your prompt — run the command yourself.", ephemeral=True
|
||
)
|
||
return False
|
||
return True
|
||
|
||
async def _on_select(self, interaction: discord.Interaction) -> None:
|
||
entry_id = int(self.select.values[0])
|
||
entry = await self.cog.get_entry(self.guild_id, self.list_kind, entry_id)
|
||
if entry is None:
|
||
await interaction.response.edit_message(
|
||
embed=err_embed("That entry no longer exists — it may have already been removed."),
|
||
view=None,
|
||
)
|
||
return
|
||
|
||
confirm_view = RemoveConfirmView(
|
||
self.cog, self.guild_id, self.list_kind, self.author_id, entry_id, entry["name"]
|
||
)
|
||
await interaction.response.edit_message(
|
||
embed=info_embed(f"Remove **{entry['name']}**? This can't be undone."),
|
||
view=confirm_view,
|
||
)
|
||
|