KaniumCogs/recruitmentCog/recruitment.py

335 lines
12 KiB
Python
Raw Permalink Normal View History

import asyncio
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
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 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):
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}
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
@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:
"""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
@commands.group(name="setapplicationschannel", invoke_without_command=True)
@checks.admin_or_permissions(manage_guild=True)
async def set_applications_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Set the channel where applications will be sent."""
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")
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")
await ctx.send("Application channel cleared.")
2023-03-12 22:58:29 +01:00
2025-05-18 13:24:16 +02:00
@commands.group(name="application", aliases=["joinus", "joinkanium", "applyformembership"], invoke_without_command=True)
2025-05-18 12:51:52 +02:00
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)
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
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
return
2023-03-13 13:10:07 +01:00
try:
2025-05-18 12:51:52 +02:00
sent = await channel.send(embed=embed)
await self.add_reactions(sent)
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
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)