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>
This commit is contained in:
Rosie Val 2026-07-28 23:21:19 +01:00
parent fdf41cfcbc
commit 940c15c27d
6 changed files with 745 additions and 1 deletions

View file

@ -178,6 +178,40 @@ Fires only on `on_member_remove` — covering voluntary leaves, kicks, and bans
---
# Affiliates
**Ping-on-Join, a persistent affiliate-server board, and a separate DM-affiliates list — all in one cog.**
**POJ (Ping on Join)** mentions new human members in configured channels, then auto-deletes the ping after a few seconds — enough to trigger a push notification without leaving clutter behind. **Affiliates** is a staff-configured channel holding a persistent, auto-updating plain-text board listing partner servers (name + invite), 10 per message and numbered 1..x, managed entirely through Discord UI — no raw IDs to remember, no manual message editing. **DM Affiliates** mirrors the same add/remove/list management on a completely separate list that isn't posted anywhere; instead every new member gets it as a DM the moment they join.
## Feature Highlights
| Category | Features |
|----------|----------|
| **POJ** | Multiple configurable ping channels, configurable delay (1–60s), configurable `{member}` message template |
| **Affiliate Board** | Modal-based add (Server Name + Server Invite), select-menu + confirmation removal, always-contiguous 1..x numbering, 10 entries per plain-text message (a new message per additional 10, up to 100 total) |
| **Persistence** | Each board page is stored by message ID and automatically recreated if deleted — survives every bot restart |
| **DM Affiliates** | Identical add/remove/list management on a fully separate list, sent as a DM on join, independently toggleable |
| **Access** | Management commands are admin-only; `list` previews are open to any member |
## Command Reference
| Command | Description |
|---------|-------------|
| `[p]poj enable` / `disable` | Toggle Ping on Join |
| `[p]poj channel add/remove/list` | Channels that ping new members |
| `[p]poj delay <seconds>` | Delete-delay for the ping |
| `[p]poj message <template>` | Ping message template (must include `{member}`) |
| `[p]aff channel #channel` | Set the affiliate board channel |
| `[p]aff add` | Add an affiliate (button → modal) |
| `[p]aff remove` | Remove an affiliate (select → confirm) |
| `[p]aff list` | Preview the current board |
| `[p]aff refresh` | Force-recreate the persistent message |
| `[p]aff dm add/remove/list` | Same management, separate DM-only list |
| `[p]aff dm enable/disable` | Toggle DMing new members the list |
---
# ImageVault
**Auto-deletes posted images and re-hosts them in a private vault channel so they can be retrieved on request.**

20
affiliates/__init__.py Normal file
View file

@ -0,0 +1,20 @@
"""
Affiliates - Ping on Join (POJ), a persistent affiliate-server board, and a
separate DM-affiliates list sent to new members on join.
"""
from redbot.core.bot import Red
from .affiliates import Affiliates
__red_end_user_data_statement__ = (
"This cog stores configuration data (POJ channels/settings, the affiliate board channel, and the "
"affiliate/DM-affiliate entries themselves) per server. Each affiliate entry records the server name, "
"invite link, the Discord user ID of whoever added it, and a timestamp. This data is not sent anywhere "
"off the host running the bot."
)
async def setup(bot: Red) -> None:
await bot.add_cog(Affiliates(bot))

477
affiliates/affiliates.py Normal file
View file

@ -0,0 +1,477 @@
"""
Affiliates - Ping on Join (POJ), a persistent affiliate-server board, and a
separate DM-affiliates list sent to new members on join.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Union
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from .views import AddAffiliateButtonView, RemoveSelectView, ok_embed, info_embed, err_embed
LOG = logging.getLogger("red.evecogs.affiliates")
COLOUR_INFO = discord.Colour.blurple()
LIST_KEYS = {"aff": "affiliates", "dm": "dm_affiliates"}
NEXT_ID_KEYS = {"aff": "aff_next_id", "dm": "dm_next_id"}
MAX_ENTRIES = 100 # 10 messages x 10 entries/message
def _chunk(items: List[dict], size: int = 10) -> List[List[dict]]:
return [items[i:i + size] for i in range(0, len(items), size)]
def render_pages(entries: List[dict], guild_name: str) -> List[str]:
"""Render entries into plain-text message bodies, 10 per message.
Deliberately plain text, not embeds/Components V2: a LayoutView's real
budget (verified against the installed discord.py — 40 total children,
4000 total text characters across the *whole* view) can't safely hold
even one 10-entry page in the worst case, and Discord's own 2000-char
message-content cap is what actually bounds this — see AffiliateModal's
max_length comment for the field-length math that keeps every page safe.
"""
header = f"**{guild_name}'s Affiliations:**"
if not entries:
return [f"{header}\n\nNothing here yet."]
chunks = _chunk(entries, 10)[:10] # cap at 10 pages = 100 entries
pages = []
for page_idx, chunk in enumerate(chunks):
blocks = [header] if page_idx == 0 else []
for i, entry in enumerate(chunk):
position = page_idx * 10 + i + 1
blocks.append(f"{position}. {entry['name']} __==__~~--~~ {entry['invite']}")
pages.append("\n\n".join(blocks))
return pages
class Affiliates(commands.Cog):
"""POJ (Ping on Join), an affiliate-server board, and DM affiliates."""
__version__ = "1.0.0"
__author__ = ["everestmcarthur"]
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=0x6166_6669_6C69_6174_6573, force_registration=True)
default_guild: Dict[str, Any] = {
# POJ
"poj_enabled": False,
"poj_channels": [],
"poj_delay": 5,
"poj_message": "{member}",
# Affiliates (channel board)
"aff_channel": None,
"aff_message_ids": [], # one message ID per 10-entry page
"aff_next_id": 1,
"affiliates": [],
# DM Affiliates (separate list, no channel)
"dm_enabled": False,
"dm_next_id": 1,
"dm_affiliates": [],
}
self.config.register_guild(**default_guild)
# ══════════════════════════════════════════════════════════
# Shared storage helpers (used by both commands and views.py)
# ══════════════════════════════════════════════════════════
async def add_entry(
self, guild_id: int, list_kind: str, name: str, invite: str, added_by: int
) -> Optional[int]:
"""Append an entry; returns its 1-indexed display position, or None if full."""
list_key = LIST_KEYS[list_kind]
id_key = NEXT_ID_KEYS[list_kind]
guild_conf = self.config.guild_from_id(guild_id)
async with guild_conf.all() as conf:
entries = conf[list_key]
if len(entries) >= MAX_ENTRIES:
return None
entry_id = conf[id_key]
conf[id_key] = entry_id + 1
entries.append({
"id": entry_id,
"name": name,
"invite": invite,
"added_by": added_by,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
position = len(entries)
if list_kind == "aff":
guild = self.bot.get_guild(guild_id)
if guild:
await self._sync_aff_message(guild)
return position
async def remove_entry(self, guild_id: int, list_kind: str, entry_id: int) -> bool:
list_key = LIST_KEYS[list_kind]
guild_conf = self.config.guild_from_id(guild_id)
async with guild_conf.all() as conf:
entries = conf[list_key]
filtered = [e for e in entries if e["id"] != entry_id]
if len(filtered) == len(entries):
return False
conf[list_key] = filtered
if list_kind == "aff":
guild = self.bot.get_guild(guild_id)
if guild:
await self._sync_aff_message(guild)
return True
async def get_entry(self, guild_id: int, list_kind: str, entry_id: int) -> Optional[dict]:
list_key = LIST_KEYS[list_kind]
entries = await getattr(self.config.guild_from_id(guild_id), list_key)()
for entry in entries:
if entry["id"] == entry_id:
return entry
return None
async def _sync_aff_message(self, guild: discord.Guild) -> None:
"""(Re)render the persistent affiliate board — one plain-text message per
10-entry page. Creates new pages as the list grows, edits existing pages
in place, and deletes trailing pages if the list shrank."""
conf = await self.config.guild(guild).all()
channel_id = conf["aff_channel"]
if not channel_id:
return
channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.TextChannel):
return
pages = render_pages(conf["affiliates"], guild.name)
old_ids = conf["aff_message_ids"]
new_ids: List[int] = []
for i, page_text in enumerate(pages):
message = None
if i < len(old_ids):
try:
message = await channel.fetch_message(old_ids[i])
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
message = None
if message:
try:
await message.edit(content=page_text)
new_ids.append(message.id)
continue
except discord.HTTPException:
pass # fall through and recreate this page below
try:
new_message = await channel.send(page_text)
except discord.HTTPException:
LOG.exception("Affiliates: failed to send board page %d in guild %s", i + 1, guild.id)
continue
new_ids.append(new_message.id)
# The list shrank — clean up now-unused trailing pages.
for stale_id in old_ids[len(pages):]:
try:
stale_message = await channel.fetch_message(stale_id)
await stale_message.delete()
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
await self.config.guild(guild).aff_message_ids.set(new_ids)
# ══════════════════════════════════════════════════════════
# on_member_join: POJ + DM affiliates
# ══════════════════════════════════════════════════════════
async def _fire_poj(self, member: discord.Member, conf: Dict[str, Any]) -> None:
channels = conf["poj_channels"]
if not channels:
return
template = conf["poj_message"] or "{member}"
delay = conf["poj_delay"]
content = template.replace("{member}", member.mention)
async def _ping_one(channel_id: int) -> None:
channel = member.guild.get_channel(channel_id)
if not isinstance(channel, (discord.TextChannel, discord.VoiceChannel, discord.Thread)):
return
perms = channel.permissions_for(member.guild.me)
if not perms.send_messages:
return
try:
msg = await channel.send(content, allowed_mentions=discord.AllowedMentions(users=True))
except discord.HTTPException:
return
await asyncio.sleep(delay)
try:
await msg.delete()
except discord.HTTPException:
pass
# Fire-and-forget per channel so a slow/rate-limited channel never
# delays the ping (or the DM affiliate send) in any other channel.
for channel_id in channels:
asyncio.create_task(_ping_one(channel_id))
async def _send_dm_affiliates(self, member: discord.Member, conf: Dict[str, Any]) -> None:
entries = conf["dm_affiliates"]
if not entries:
return
pages = render_pages(entries, member.guild.name)
try:
for page_text in pages:
await member.send(page_text)
except (discord.Forbidden, discord.HTTPException):
pass # DMs closed or another delivery failure — not actionable
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
if member.bot:
return
conf = await self.config.guild(member.guild).all()
if conf["poj_enabled"]:
await self._fire_poj(member, conf)
if conf["dm_enabled"]:
await self._send_dm_affiliates(member, conf)
# ══════════════════════════════════════════════════════════
# POJ commands — every subcommand here is admin-only, so the check
# lives once on the top-level group.
# ══════════════════════════════════════════════════════════
@commands.group(name="poj")
@commands.guild_only()
@commands.admin_or_permissions(administrator=True)
async def poj(self, ctx: commands.Context):
"""👋 POJ — Ping on Join: mention new members, then auto-delete the ping."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@poj.command(name="enable", aliases=["on"])
async def poj_enable(self, ctx: commands.Context):
"""Enable POJ for this server."""
await self.config.guild(ctx.guild).poj_enabled.set(True)
await ctx.send(embed=ok_embed("✅ POJ enabled."))
@poj.command(name="disable", aliases=["off"])
async def poj_disable(self, ctx: commands.Context):
"""Disable POJ for this server."""
await self.config.guild(ctx.guild).poj_enabled.set(False)
await ctx.send(embed=info_embed("POJ disabled."))
@poj.group(name="channel")
async def poj_channel(self, ctx: commands.Context):
"""Manage which channels ping new members on join."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@poj_channel.command(name="add")
async def poj_channel_add(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).poj_channels() as chans:
if channel.id not in chans:
chans.append(channel.id)
await ctx.send(embed=ok_embed(f"✅ {channel.mention} will now ping new members."))
@poj_channel.command(name="remove")
async def poj_channel_remove(
self, ctx: commands.Context,
channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread],
):
async with self.config.guild(ctx.guild).poj_channels() as chans:
if channel.id in chans:
chans.remove(channel.id)
await ctx.send(embed=ok_embed(f"✅ {channel.mention} removed from POJ."))
@poj_channel.command(name="list")
async def poj_channel_list(self, ctx: commands.Context):
chans = await self.config.guild(ctx.guild).poj_channels()
if not chans:
return await ctx.send(embed=info_embed("No POJ channels configured."))
await ctx.send("POJ channels:\n" + "\n".join(f"<#{c}>" for c in chans))
@poj.command(name="delay")
async def poj_delay(self, ctx: commands.Context, seconds: int):
"""Set how long (1-60s) the ping stays before auto-deleting."""
if seconds < 1 or seconds > 60:
return await ctx.send(embed=err_embed("Delay must be between 1 and 60 seconds."))
await self.config.guild(ctx.guild).poj_delay.set(seconds)
await ctx.send(embed=ok_embed(f"✅ POJ delete-delay set to **{seconds}s**."))
@poj.command(name="message")
async def poj_message(self, ctx: commands.Context, *, template: str):
"""Set the ping message template. Must include `{member}`."""
if "{member}" not in template:
return await ctx.send(embed=err_embed("Template must include `{member}`."))
await self.config.guild(ctx.guild).poj_message.set(template)
await ctx.send(embed=ok_embed("✅ POJ message template updated."))
@poj.command(name="settings", aliases=["status"])
async def poj_settings(self, ctx: commands.Context):
conf = await self.config.guild(ctx.guild).all()
embed = discord.Embed(title="👋 POJ Settings", colour=COLOUR_INFO)
embed.add_field(name="Status", value="✅ Enabled" if conf["poj_enabled"] else "❌ Disabled", inline=True)
embed.add_field(name="Delay", value=f"{conf['poj_delay']}s", inline=True)
embed.add_field(
name="Channels",
value=", ".join(f"<#{c}>" for c in conf["poj_channels"]) or "None",
inline=False,
)
embed.add_field(name="Message Template", value=f"`{conf['poj_message']}`", inline=False)
embed.set_footer(text=f"Affiliates v{self.__version__}")
await ctx.send(embed=embed)
# ══════════════════════════════════════════════════════════
# Affiliate board commands — mixed access, so the admin check goes on
# each management subcommand individually; `list` stays open to anyone.
# ══════════════════════════════════════════════════════════
@commands.group(name="aff", aliases=["affiliate", "affiliates"])
@commands.guild_only()
async def aff(self, ctx: commands.Context):
"""🤝 Affiliates — the affiliate-server board and DM affiliates."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@aff.command(name="channel")
@commands.admin_or_permissions(administrator=True)
async def aff_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel the affiliate board is posted/kept updated in."""
perms = channel.permissions_for(ctx.guild.me)
if not perms.send_messages:
return await ctx.send(embed=err_embed(f"I need Send Messages in {channel.mention}."))
await self.config.guild(ctx.guild).aff_channel.set(channel.id)
await self.config.guild(ctx.guild).aff_message_ids.set([])
await self._sync_aff_message(ctx.guild)
await ctx.send(embed=ok_embed(f"✅ Affiliate board set to {channel.mention}."))
@aff.command(name="add")
@commands.admin_or_permissions(administrator=True)
async def aff_add(self, ctx: commands.Context):
"""Add a new affiliate via a short modal (Server Name + Server Invite)."""
channel_id = await self.config.guild(ctx.guild).aff_channel()
if not channel_id:
return await ctx.send(embed=err_embed("Set a board channel first: `[p]aff channel #channel`."))
view = AddAffiliateButtonView(self, ctx.guild.id, "aff", ctx.author.id)
await ctx.send(embed=info_embed("Click below to add a new affiliate."), view=view)
@aff.command(name="remove")
@commands.admin_or_permissions(administrator=True)
async def aff_remove(self, ctx: commands.Context, page: int = 1):
"""Remove an affiliate via a select menu + confirmation.
Pass a page number if there are more than 25 affiliates (each select menu can only list 25 at a time).
"""
entries = await self.config.guild(ctx.guild).affiliates()
if not entries:
return await ctx.send(embed=info_embed("There are no affiliates to remove."))
total_pages = (len(entries) + 24) // 25
page = max(1, min(page, total_pages))
view = RemoveSelectView(self, ctx.guild.id, "aff", ctx.author.id, entries, page)
desc = "Choose an affiliate to remove:"
if total_pages > 1:
desc += f"\nPage **{page}/{total_pages}** — run `[p]aff remove <page>` to see others."
await ctx.send(embed=info_embed(desc), view=view)
@aff.command(name="list")
async def aff_list(self, ctx: commands.Context):
"""Preview the current affiliate list."""
entries = await self.config.guild(ctx.guild).affiliates()
for page_text in render_pages(entries, ctx.guild.name):
await ctx.send(page_text)
@aff.command(name="refresh")
@commands.admin_or_permissions(administrator=True)
async def aff_refresh(self, ctx: commands.Context):
"""Force-recreate/resync the persistent board message (recovery command)."""
channel_id = await self.config.guild(ctx.guild).aff_channel()
if not channel_id:
return await ctx.send(embed=err_embed("No board channel configured yet."))
await self._sync_aff_message(ctx.guild)
await ctx.send(embed=ok_embed("✅ Affiliate board refreshed."))
@aff.command(name="settings", aliases=["status"])
@commands.admin_or_permissions(administrator=True)
async def aff_settings(self, ctx: commands.Context):
conf = await self.config.guild(ctx.guild).all()
channel = ctx.guild.get_channel(conf["aff_channel"]) if conf["aff_channel"] else None
embed = discord.Embed(title="🤝 Affiliates Settings", colour=COLOUR_INFO)
embed.add_field(name="Board Channel", value=channel.mention if channel else "Not set", inline=True)
embed.add_field(name="Affiliates", value=str(len(conf["affiliates"])), inline=True)
embed.add_field(
name="DM Affiliates",
value=f"{'✅ Enabled' if conf['dm_enabled'] else '❌ Disabled'} — {len(conf['dm_affiliates'])} entries",
inline=False,
)
embed.set_footer(text=f"Affiliates v{self.__version__}")
await ctx.send(embed=embed)
# ── DM affiliates ────────────────────────────────────────
@aff.group(name="dm")
async def aff_dm(self, ctx: commands.Context):
"""Manage the separate DM-affiliates list, sent to new members on join."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@aff_dm.command(name="add")
@commands.admin_or_permissions(administrator=True)
async def aff_dm_add(self, ctx: commands.Context):
"""Add a new DM affiliate via a short modal (Server Name + Server Invite)."""
view = AddAffiliateButtonView(self, ctx.guild.id, "dm", ctx.author.id)
await ctx.send(embed=info_embed("Click below to add a new DM affiliate."), view=view)
@aff_dm.command(name="remove")
@commands.admin_or_permissions(administrator=True)
async def aff_dm_remove(self, ctx: commands.Context, page: int = 1):
"""Remove a DM affiliate via a select menu + confirmation.
Pass a page number if there are more than 25 DM affiliates (each select menu can only list 25 at a time).
"""
entries = await self.config.guild(ctx.guild).dm_affiliates()
if not entries:
return await ctx.send(embed=info_embed("There are no DM affiliates to remove."))
total_pages = (len(entries) + 24) // 25
page = max(1, min(page, total_pages))
view = RemoveSelectView(self, ctx.guild.id, "dm", ctx.author.id, entries, page)
desc = "Choose a DM affiliate to remove:"
if total_pages > 1:
desc += f"\nPage **{page}/{total_pages}** — run `[p]aff dm remove <page>` to see others."
await ctx.send(embed=info_embed(desc), view=view)
@aff_dm.command(name="list")
async def aff_dm_list(self, ctx: commands.Context):
"""Preview the current DM-affiliate list."""
entries = await self.config.guild(ctx.guild).dm_affiliates()
for page_text in render_pages(entries, ctx.guild.name):
await ctx.send(page_text)
@aff_dm.command(name="enable", aliases=["on"])
@commands.admin_or_permissions(administrator=True)
async def aff_dm_enable(self, ctx: commands.Context):
"""Start DMing new members the DM-affiliate list on join."""
await self.config.guild(ctx.guild).dm_enabled.set(True)
await ctx.send(embed=ok_embed("✅ New members will now be DMed the DM-affiliate list."))
@aff_dm.command(name="disable", aliases=["off"])
@commands.admin_or_permissions(administrator=True)
async def aff_dm_disable(self, ctx: commands.Context):
"""Stop DMing new members the DM-affiliate list."""
await self.config.guild(ctx.guild).dm_enabled.set(False)
await ctx.send(embed=info_embed("DM affiliates disabled."))

22
affiliates/info.json Normal file
View file

@ -0,0 +1,22 @@
{
"author": ["everestmcarthur"],
"install_msg": "Thanks for installing **Affiliates**! Set up POJ with `[p]poj channel add #channel` then `[p]poj enable`. Set up the affiliate board with `[p]aff channel #channel` then `[p]aff add`. DM affiliates work the same way via `[p]aff dm add`, then `[p]aff dm enable` to start sending them to new members.",
"name": "Affiliates",
"short": "Ping-on-Join, a persistent affiliate-server board, and a separate DM-affiliates list sent to new members.",
"description": "Affiliates bundles three related join-time features. POJ (Ping on Join) mentions new human members in configured channels then auto-deletes the ping after a configurable delay, so members get notified without cluttering chat history. The affiliate board is a staff-configured channel holding a persistent, auto-updating message listing partner servers (name + invite), numbered 1..x, managed entirely through Discord UI: `aff add` opens a modal (Server Name + Server Invite), `aff remove` opens a select menu with a confirmation step, and `aff list` previews the current entries. The board message survives bot restarts and is recreated automatically if it's ever deleted. DM Affiliates mirrors that same add/remove/list management on a completely separate list that isn't posted anywhere — instead every new member is sent it as a DM the moment they join, independently toggleable from POJ and the board.",
"min_bot_version": "3.5.0",
"min_python_version": [3, 9, 0],
"tags": [
"affiliates",
"welcome",
"join",
"ping",
"partners",
"advertising",
"modal",
"dm"
],
"type": "COG",
"requirements": []
}

191
affiliates/views.py Normal file
View file

@ -0,0 +1,191 @@
"""
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,
)

View file

@ -3,6 +3,6 @@
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
"name": "EveCogs",
"short": "A collection of advanced cogs for Red-DiscordBot.",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), Support (modernized contact/dm system replacing Red's built-in commands with categories, logging, blocking & customization), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs), GhostWipe (auto-deletes a departing member's messages server-wide on leave/kick/ban, with a full HTML audit-log viewer), and ImageVault (auto-deletes posted images, re-hosting them in a private vault channel first so they can be retrieved on request, with spoiler tags preserved).",
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), Support (modernized contact/dm system replacing Red's built-in commands with categories, logging, blocking & customization), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs), GhostWipe (auto-deletes a departing member's messages server-wide on leave/kick/ban, with a full HTML audit-log viewer), ImageVault (auto-deletes posted images, re-hosting them in a private vault channel first so they can be retrieved on request, with spoiler tags preserved), and Affiliates (Ping-on-Join, a persistent modal/select-managed affiliate-server board, and a separate DM-affiliates list sent to new members on join).",
"type": "REPO"
}