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:
parent
fdf41cfcbc
commit
940c15c27d
6 changed files with 745 additions and 1 deletions
34
README.md
34
README.md
|
|
@ -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
20
affiliates/__init__.py
Normal 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
477
affiliates/affiliates.py
Normal 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
22
affiliates/info.json
Normal 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
191
affiliates/views.py
Normal 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,
|
||||
)
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue