2023-03-12 12:13:30 +01:00
|
|
|
import asyncio
|
2023-03-13 17:20:28 +01:00
|
|
|
import datetime
|
2023-03-16 19:13:37 +01:00
|
|
|
import re
|
2025-05-18 12:51:52 +02:00
|
|
|
from typing import Dict, List, Optional
|
2023-03-16 19:13:37 +01:00
|
|
|
|
2023-03-12 22:55:57 +01:00
|
|
|
import discord
|
2023-03-12 22:58:29 +01:00
|
|
|
from redbot.core import Config, checks, commands
|
2023-03-12 12:13:30 +01:00
|
|
|
from redbot.core.bot import Red
|
2023-03-16 19:13:37 +01:00
|
|
|
from redbot.core.utils.antispam import AntiSpam
|
|
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
# Define your application questions and field settings\
|
|
|
|
|
QUESTIONS_LIST = [
|
|
|
|
|
{
|
|
|
|
|
"key": "name",
|
|
|
|
|
"prompt": "First of all, what is your name?",
|
|
|
|
|
"field_name": "Name",
|
|
|
|
|
"inline": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"key": "age",
|
|
|
|
|
"prompt": "What age are you?",
|
|
|
|
|
"field_name": "Age",
|
|
|
|
|
"inline": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"key": "country",
|
|
|
|
|
"prompt": "Where are you from?",
|
|
|
|
|
"field_name": "Country",
|
|
|
|
|
"inline": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"key": "hobbies",
|
|
|
|
|
"prompt": "Do you have any hobbies?",
|
|
|
|
|
"field_name": "Hobbies",
|
|
|
|
|
"inline": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"key": "game",
|
|
|
|
|
"prompt": "Are you wishing to join because of a particular game? If so, which game?",
|
|
|
|
|
"field_name": "Specific game?",
|
|
|
|
|
"inline": True,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"key": "motivation",
|
|
|
|
|
"prompt": "Write out, in a free-style way, what your motivation is for wanting to join us in particular and how you would be a good fit for Kanium",
|
|
|
|
|
"field_name": "Motivation for wanting to join:",
|
|
|
|
|
"inline": False,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
2023-03-16 19:13:37 +01:00
|
|
|
|
|
|
|
|
def sanitize_input(input_text: str) -> str:
|
2025-05-18 12:51:52 +02:00
|
|
|
"""Sanitize input to remove mentions, links, and unwanted special characters."""
|
2025-05-18 13:07:11 +02:00
|
|
|
# Remove user/role/channel mentions
|
2025-05-18 12:51:52 +02:00
|
|
|
text = re.sub(r'<@!?(?:&)?\d+>', '', input_text)
|
2025-05-18 13:07:11 +02:00
|
|
|
# Remove URLs
|
2025-05-18 12:51:52 +02:00
|
|
|
text = re.sub(r'http\S+', '', text)
|
2025-05-18 13:07:11 +02:00
|
|
|
# Keep only word characters (including Unicode letters/digits), whitespace, and your chosen punctuation
|
|
|
|
|
text = re.sub(r"[^\w\s\.,\?!`~@#$%^&*()_+=-]", "", text)
|
2025-05-18 12:51:52 +02:00
|
|
|
return text
|
2023-03-12 12:13:30 +01:00
|
|
|
|
2023-03-12 21:41:08 +01:00
|
|
|
|
2025-05-18 13:07:11 +02:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
class Recruitment(commands.Cog): # noqa
|
2023-03-12 22:58:29 +01:00
|
|
|
"""A cog that lets a user send a membership application."""
|
2023-03-12 21:41:08 +01:00
|
|
|
|
2023-03-12 22:55:57 +01:00
|
|
|
def __init__(self, bot: Red):
|
2023-03-12 12:13:30 +01:00
|
|
|
self.bot = bot
|
2025-05-18 12:51:52 +02:00
|
|
|
self.config = Config.get_conf(self, identifier=0xFAB123ABC456)
|
|
|
|
|
default_guild = {"application_channel_id": None}
|
2023-03-13 18:30:59 +01:00
|
|
|
self.config.register_guild(**default_guild)
|
2025-05-18 12:51:52 +02:00
|
|
|
# antispam store per guild per user
|
|
|
|
|
self.antispam: Dict[int, Dict[int, AntiSpam]] = {}
|
|
|
|
|
self.cog_check_enabled = True
|
|
|
|
|
|
|
|
|
|
async def cog_check(self, ctx: commands.Context) -> bool:
|
|
|
|
|
if (
|
|
|
|
|
await ctx.bot.is_admin(ctx.author)
|
|
|
|
|
or not self.cog_check_enabled
|
|
|
|
|
or (ctx.guild and ctx.author.guild_permissions.manage_guild)
|
|
|
|
|
or (ctx.guild and ctx.author.guild_permissions.administrator)
|
|
|
|
|
):
|
2023-03-16 20:06:19 +01:00
|
|
|
return True
|
2023-03-16 20:09:22 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
gid = ctx.guild.id
|
|
|
|
|
uid = ctx.author.id
|
|
|
|
|
if gid not in self.antispam:
|
|
|
|
|
self.antispam[gid] = {}
|
|
|
|
|
if uid not in self.antispam[gid]:
|
|
|
|
|
self.antispam[gid][uid] = AntiSpam([(datetime.timedelta(hours=1), 1)])
|
|
|
|
|
spam = self.antispam[gid][uid]
|
|
|
|
|
if spam.spammy:
|
2023-03-16 20:09:22 +01:00
|
|
|
try:
|
2025-05-18 12:51:52 +02:00
|
|
|
await ctx.message.delete()
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
await ctx.author.send("Please wait for an hour before sending another application.")
|
2023-03-16 20:09:22 +01:00
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
2023-03-16 19:39:10 +01:00
|
|
|
return False
|
2025-05-18 12:51:52 +02:00
|
|
|
spam.stamp()
|
2023-03-16 19:39:10 +01:00
|
|
|
return True
|
2023-03-16 19:34:01 +01:00
|
|
|
|
2023-11-22 20:53:09 +01:00
|
|
|
@commands.command(name="togglecogcheck")
|
2025-05-18 12:51:52 +02:00
|
|
|
@checks.is_owner()
|
|
|
|
|
async def toggle_cog_check(self, ctx: commands.Context) -> None:
|
2023-11-22 20:53:09 +01:00
|
|
|
"""Toggle the cog_check functionality on or off."""
|
|
|
|
|
self.cog_check_enabled = not self.cog_check_enabled
|
2025-05-18 12:51:52 +02:00
|
|
|
await ctx.send(f"Cog checks are now {'enabled' if self.cog_check_enabled else 'disabled'}.")
|
2023-03-16 20:09:22 +01:00
|
|
|
|
2025-05-18 13:18:50 +02:00
|
|
|
@commands.group(name="setapplicationschannel", invoke_without_command=True)
|
2023-03-13 18:30:59 +01:00
|
|
|
@checks.admin_or_permissions(manage_guild=True)
|
2025-05-18 13:18:50 +02:00
|
|
|
async def set_applications_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
2023-03-13 18:30:59 +01:00
|
|
|
"""Set the channel where applications will be sent."""
|
2025-05-18 13:12:25 +02:00
|
|
|
channel = channel or ctx.channel
|
|
|
|
|
await self.config.guild(ctx.guild).application_channel_id.set(channel.id)
|
|
|
|
|
await ctx.send(f"Application channel set to {channel.mention}.")
|
2025-05-18 12:51:52 +02:00
|
|
|
|
|
|
|
|
@set_applications_channel.command(name="clear")
|
2025-05-18 13:18:50 +02:00
|
|
|
async def clear_applications_channel(self, ctx: commands.Context):
|
|
|
|
|
"""Clear the application channel."""
|
2025-05-18 12:51:52 +02:00
|
|
|
await self.config.guild(ctx.guild).clear_raw("application_channel_id")
|
2023-03-13 18:30:59 +01:00
|
|
|
await ctx.send("Application channel cleared.")
|
2023-03-12 22:58:29 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
@commands.group(name="application", invoke_without_command=True)
|
|
|
|
|
async def application(self, ctx: commands.Context, *, text: Optional[str] = None) -> None:
|
|
|
|
|
"""Start an application process."""
|
|
|
|
|
# Direct free-text application (optional)
|
|
|
|
|
if text:
|
|
|
|
|
text = sanitize_input(text)
|
|
|
|
|
if len(text) > 2000:
|
|
|
|
|
await ctx.send("Your application is too long (max 2000 chars).")
|
|
|
|
|
return
|
|
|
|
|
# Determine if in guild or DM
|
|
|
|
|
if isinstance(ctx.channel, discord.DMChannel):
|
|
|
|
|
await self._start_application(ctx.author)
|
|
|
|
|
else:
|
2023-03-13 18:44:48 +01:00
|
|
|
try:
|
|
|
|
|
await ctx.message.delete()
|
2025-05-18 12:51:52 +02:00
|
|
|
except discord.Forbidden:
|
2023-03-13 18:44:48 +01:00
|
|
|
pass
|
2025-05-18 12:51:52 +02:00
|
|
|
try:
|
|
|
|
|
await ctx.author.send(
|
|
|
|
|
"Let's move this to DM so we can process your application."
|
|
|
|
|
)
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
await ctx.send("I cannot DM you. Please enable DMs and try again.")
|
|
|
|
|
return
|
|
|
|
|
await self._start_application(ctx.author)
|
|
|
|
|
|
|
|
|
|
async def _start_application(self, member: discord.Member) -> None:
|
|
|
|
|
# Kick off interactive questions
|
|
|
|
|
if not await self._send_embed(
|
|
|
|
|
member,
|
2023-03-13 15:40:22 +01:00
|
|
|
title="+++ KANIUM APPLICATION SYSTEM +++",
|
2025-05-18 12:51:52 +02:00
|
|
|
description=(
|
2025-05-18 12:58:41 +02:00
|
|
|
"This is the process to join Kanium."
|
2025-05-18 12:51:52 +02:00
|
|
|
" Please take your time and answer thoughtfully."
|
|
|
|
|
),
|
2023-03-13 13:33:26 +01:00
|
|
|
color=discord.Color.green(),
|
2025-05-18 12:51:52 +02:00
|
|
|
):
|
|
|
|
|
return
|
2023-03-12 22:55:57 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
answers = await self.ask_questions(member)
|
2023-03-13 16:27:56 +01:00
|
|
|
if not answers:
|
|
|
|
|
return
|
2025-05-18 12:51:52 +02:00
|
|
|
embed = self.format_application(answers, member)
|
|
|
|
|
await self.send_application(member, embed)
|
|
|
|
|
|
|
|
|
|
async def _send_embed(
|
|
|
|
|
self,
|
|
|
|
|
member: discord.Member,
|
|
|
|
|
*,
|
|
|
|
|
title: str,
|
|
|
|
|
description: str,
|
|
|
|
|
color: discord.Color,
|
|
|
|
|
) -> bool:
|
|
|
|
|
embed = discord.Embed(title=title, description=description, color=color)
|
|
|
|
|
try:
|
|
|
|
|
await member.send(embed=embed)
|
|
|
|
|
return True
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
return False
|
2023-03-12 22:55:57 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
async def ask_questions(self, member: discord.Member) -> Optional[Dict[str, str]]:
|
2025-05-18 13:03:21 +02:00
|
|
|
"""
|
|
|
|
|
Ask each question, let the user confirm their answer,
|
|
|
|
|
and return the final answers (or None on abort).
|
|
|
|
|
"""
|
2025-05-18 12:51:52 +02:00
|
|
|
answers: Dict[str, str] = {}
|
2025-05-18 12:58:41 +02:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
for q in QUESTIONS_LIST:
|
2025-05-18 12:58:41 +02:00
|
|
|
prompt = q["prompt"]
|
|
|
|
|
|
|
|
|
|
while True:
|
2025-05-18 13:03:21 +02:00
|
|
|
# 1) send the question
|
2025-05-18 12:58:41 +02:00
|
|
|
try:
|
|
|
|
|
await member.send(prompt)
|
|
|
|
|
except discord.Forbidden:
|
2025-05-18 13:03:21 +02:00
|
|
|
return None # can't DM
|
2025-05-18 12:58:41 +02:00
|
|
|
|
2025-05-18 13:03:21 +02:00
|
|
|
# 2) wait for their reply (match on ID, not object)
|
2025-05-18 12:58:41 +02:00
|
|
|
try:
|
|
|
|
|
msg = await asyncio.wait_for(
|
|
|
|
|
self.bot.wait_for(
|
|
|
|
|
"message",
|
|
|
|
|
check=lambda m: (
|
|
|
|
|
m.author.id == member.id
|
|
|
|
|
and isinstance(m.channel, discord.DMChannel)
|
2025-05-18 13:03:21 +02:00
|
|
|
)
|
2025-05-18 12:58:41 +02:00
|
|
|
),
|
|
|
|
|
timeout=300.0,
|
|
|
|
|
)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
await member.send(
|
2025-05-18 13:03:21 +02:00
|
|
|
"You took too long to answer. Please restart the application with `!application`."
|
2025-05-18 12:58:41 +02:00
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
answer = sanitize_input(msg.content)
|
|
|
|
|
|
2025-05-18 13:03:21 +02:00
|
|
|
# 3) echo back for confirmation
|
2025-05-18 12:51:52 +02:00
|
|
|
try:
|
2025-05-18 12:58:41 +02:00
|
|
|
await member.send(f"You answered:\n> {answer}\n\nIs that correct? (yes/no)")
|
2025-05-18 12:51:52 +02:00
|
|
|
except discord.Forbidden:
|
2025-05-18 12:58:41 +02:00
|
|
|
return None
|
|
|
|
|
|
2025-05-18 13:03:21 +02:00
|
|
|
# 4) wait for a yes/no
|
2025-05-18 12:58:41 +02:00
|
|
|
try:
|
|
|
|
|
confirm = await asyncio.wait_for(
|
|
|
|
|
self.bot.wait_for(
|
|
|
|
|
"message",
|
|
|
|
|
check=lambda m: (
|
|
|
|
|
m.author.id == member.id
|
|
|
|
|
and isinstance(m.channel, discord.DMChannel)
|
|
|
|
|
and m.content.lower() in ("y", "yes", "n", "no")
|
2025-05-18 13:03:21 +02:00
|
|
|
)
|
2025-05-18 12:58:41 +02:00
|
|
|
),
|
|
|
|
|
timeout=60.0,
|
|
|
|
|
)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
await member.send(
|
2025-05-18 13:03:21 +02:00
|
|
|
"Confirmation timed out. Please restart the application with `!application`."
|
2025-05-18 12:58:41 +02:00
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if confirm.content.lower() in ("y", "yes"):
|
2025-05-18 13:03:21 +02:00
|
|
|
# user confirmed, save and move on
|
2025-05-18 12:58:41 +02:00
|
|
|
answers[q["key"]] = answer
|
2025-05-18 13:03:21 +02:00
|
|
|
break
|
2025-05-18 12:58:41 +02:00
|
|
|
else:
|
2025-05-18 13:03:21 +02:00
|
|
|
# user said “no” → repeat this question
|
2025-05-18 12:58:41 +02:00
|
|
|
await member.send("Okay, let's try that again.")
|
|
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
return answers
|
2023-03-12 23:14:26 +01:00
|
|
|
|
2025-05-18 13:03:21 +02:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
def format_application(
|
|
|
|
|
self, answers: Dict[str, str], member: discord.Member
|
|
|
|
|
) -> discord.Embed:
|
|
|
|
|
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
|
|
|
trial_ends = (
|
|
|
|
|
datetime.datetime.utcnow() + datetime.timedelta(days=30)
|
|
|
|
|
).strftime("%Y-%m-%d UTC")
|
2023-03-12 22:55:57 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
embed = discord.Embed(
|
|
|
|
|
title=f"Application from {member}", color=discord.Color.green()
|
|
|
|
|
)
|
|
|
|
|
embed.set_thumbnail(url=member.avatar.url)
|
|
|
|
|
for q in QUESTIONS_LIST:
|
|
|
|
|
embed.add_field(
|
|
|
|
|
name=q["field_name"],
|
|
|
|
|
value=answers[q["key"]],
|
|
|
|
|
inline=q["inline"],
|
|
|
|
|
)
|
|
|
|
|
embed.set_footer(text=f"Received: {now} | Trial ends: {trial_ends}")
|
|
|
|
|
return embed
|
2023-03-13 18:58:47 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
async def send_application(
|
|
|
|
|
self, member: discord.Member, embed: discord.Embed
|
|
|
|
|
) -> None:
|
|
|
|
|
guild = member.guild
|
|
|
|
|
if guild is None:
|
|
|
|
|
try:
|
|
|
|
|
await member.send("You need to be in the server to apply.")
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
2023-03-13 18:58:47 +01:00
|
|
|
return
|
|
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
channel_id = await self.config.guild(guild).application_channel_id()
|
|
|
|
|
if not channel_id:
|
|
|
|
|
try:
|
|
|
|
|
await member.send(
|
|
|
|
|
"Application channel not set. Ask an admin to run `setapplicationschannel`."
|
|
|
|
|
)
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
2023-03-13 18:30:59 +01:00
|
|
|
return
|
|
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
channel = guild.get_channel(channel_id)
|
|
|
|
|
if not channel:
|
|
|
|
|
try:
|
|
|
|
|
await member.send("Application channel was not found. Please ask an admin to re-set it.")
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
2023-03-13 18:30:59 +01:00
|
|
|
return
|
2023-03-13 13:10:07 +01:00
|
|
|
|
2023-03-13 18:30:59 +01:00
|
|
|
try:
|
2025-05-18 12:51:52 +02:00
|
|
|
sent = await channel.send(embed=embed)
|
|
|
|
|
await self.add_reactions(sent)
|
2023-03-13 18:30:59 +01:00
|
|
|
except discord.Forbidden:
|
2025-05-18 12:51:52 +02:00
|
|
|
try:
|
|
|
|
|
await member.send("I cannot post or react in the application channel.")
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
2023-03-13 18:30:59 +01:00
|
|
|
return
|
2023-03-13 13:10:07 +01:00
|
|
|
|
2025-05-18 12:51:52 +02:00
|
|
|
# Assign Trial role
|
2023-06-03 17:17:18 +02:00
|
|
|
role = guild.get_role(531181363420987423)
|
2025-05-18 12:51:52 +02:00
|
|
|
if role:
|
2023-03-13 15:40:22 +01:00
|
|
|
try:
|
2025-05-18 12:51:52 +02:00
|
|
|
await member.add_roles(role)
|
|
|
|
|
await member.send("Thank you! You've been granted the Trial role.")
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
try:
|
|
|
|
|
await member.send("I lack permissions to assign roles.")
|
|
|
|
|
except discord.Forbidden:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def add_reactions(message: discord.Message) -> None:
|
|
|
|
|
for emoji in ("✅", "❌", "❓"):
|
|
|
|
|
await message.add_reaction(emoji)
|