EveCogs/affiliates/views.py
Rosie Val 940c15c27d Add Affiliates cog: Ping on Join, affiliate board, and DM affiliates
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>
2026-07-28 23:21:19 +01:00

191 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
)