aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py4
-rw-r--r--bot/exts/info/subscribe.py4
-rw-r--r--bot/exts/recruitment/helper_utils.py76
-rw-r--r--bot/helper_questions.py144
-rw-r--r--botstrap.py220
5 files changed, 379 insertions, 69 deletions
diff --git a/bot/constants.py b/bot/constants.py
index b72a70c84..9df2a0950 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -125,6 +125,8 @@ class _Channels(EnvConfig):
duck_pond = 637820308341915648
roles = 851270062434156586
+ rules = 693837295685730335
+
Channels = _Channels()
@@ -162,6 +164,7 @@ class _Roles(EnvConfig):
mod_team = 267629731250176001
owners = 267627879762755584
project_leads = 815701647526330398
+ new_helpers = 1088292464051368018
# Code Jam
jammers = 737249140966162473
@@ -187,6 +190,7 @@ class _Categories(EnvConfig):
# 2021 Summer Code Jam
summer_code_jam = 861692638540857384
+ python_help_system = 691405807388196926
Categories = _Categories()
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
index aff1302bb..90a3d4529 100644
--- a/bot/exts/info/subscribe.py
+++ b/bot/exts/info/subscribe.py
@@ -12,6 +12,7 @@ from pydis_core.utils import members
from bot import constants
from bot.bot import Bot
from bot.decorators import redirect_output
+from bot.helper_questions import HelperingButton
from bot.log import get_logger
from bot.utils.channel import get_or_fetch_channel
@@ -78,7 +79,8 @@ class RoleButtonView(discord.ui.View):
self.interaction_owner = member
author_roles = [role.id for role in member.roles]
- for index, role in enumerate(assignable_roles):
+ self.add_item(HelperingButton(constants.Roles.new_helpers in author_roles, 0))
+ for index, role in enumerate(assignable_roles, start=1):
row = index // ITEMS_PER_ROW
self.add_item(SingleRoleButton(role, role.role_id in author_roles, row))
diff --git a/bot/exts/recruitment/helper_utils.py b/bot/exts/recruitment/helper_utils.py
new file mode 100644
index 000000000..ca8ac3760
--- /dev/null
+++ b/bot/exts/recruitment/helper_utils.py
@@ -0,0 +1,76 @@
+import datetime as dt
+import random
+import re
+
+import arrow
+import discord
+from async_rediscache import RedisCache
+from discord import Message
+from discord.ext.commands import Cog
+
+from bot.bot import Bot
+from bot.constants import Channels, Roles
+from bot.log import get_logger
+
+OT_CHANNEL_IDS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
+NEW_HELPER_ROLE_ID = Roles.new_helpers
+
+log = get_logger(__name__)
+
+URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE)
+
+
+class NewHelperUtils(Cog):
+ """Manages functionality for new helpers in April 2023."""
+
+ # RedisCache[discord.Channel.id, UtcPosixTimestamp]
+ cooldown_cache = RedisCache()
+
+ CACHE_KEY = "LAST_PING"
+
+ COOLDOWN_DURATION = dt.timedelta(minutes=10)
+ MESSAGES = [
+ f"<@&{NEW_HELPER_ROLE_ID}> can someone please answer this??",
+ f"Someone answer this <@&{NEW_HELPER_ROLE_ID}> if you want to keep your role",
+ f"Where are my <@&{NEW_HELPER_ROLE_ID}> at?",
+ f"<@&{NEW_HELPER_ROLE_ID}>, answer this!",
+ f"What's the point of having all these new <@&{NEW_HELPER_ROLE_ID}> if questions are going unanswered?",
+ ]
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.last_pinged = arrow.get(0) # Ready to fire if it can't be loaded from the cache.
+
+ async def cog_load(self) -> None:
+ """Load the most recent activation time from the cache."""
+ self.last_pinged = arrow.get(await self.cooldown_cache.get(self.CACHE_KEY, 0))
+
+ @staticmethod
+ def _is_question(message: str) -> bool:
+ """Return True if `message` appears to be a question, else False!"""
+ return '?' in URL_RE.sub('', message)
+
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """
+ This is an event listener.
+
+ Ping helpers in off-topic channels whenever someone asks a question, at most
+ as often `COOLDOWN_DURATION`.
+ """
+ if message.author.bot or message.channel.id not in OT_CHANNEL_IDS:
+ return
+
+ if arrow.utcnow() - self.last_pinged < self.COOLDOWN_DURATION:
+ return
+
+ if self._is_question(message.content):
+ self.last_pinged = arrow.utcnow()
+ allowed_mentions = discord.AllowedMentions(everyone=False, roles=[discord.Object(NEW_HELPER_ROLE_ID)])
+ await message.reply(random.choice(self.MESSAGES), allowed_mentions=allowed_mentions)
+ await self.cooldown_cache.set(self.CACHE_KEY, self.last_pinged.timestamp())
+
+
+async def setup(bot: Bot) -> None:
+ """Load the OffTopicNames cog."""
+ await bot.add_cog(NewHelperUtils(bot))
diff --git a/bot/helper_questions.py b/bot/helper_questions.py
new file mode 100644
index 000000000..a1f69945a
--- /dev/null
+++ b/bot/helper_questions.py
@@ -0,0 +1,144 @@
+from collections import namedtuple
+from string import ascii_lowercase, ascii_uppercase
+from textwrap import dedent
+
+import discord
+
+from bot.constants import Roles
+
+Question = namedtuple("question", ("question", "answers"))
+
+questions = [
+ Question(
+ question="How do you print in python?",
+ answers=(
+ "`print()`",
+ "`sys.stdout.write()`",
+ "None of the above",
+ "All of the above"
+ )
+ ),
+ Question(
+ question=dedent(
+ """
+ A user opens a help channel with the following information:
+ > Help, my code is broken.
+
+ They are in a hurry, so there's no time for back-and-forth debugging the issue.
+ Is the solution to their issue:
+ """
+ ).strip(),
+ answers=(
+ 'Replace `password == "123" or "456"` with `password in ("123", "456")`',
+ "Downgrade to 3.10 because `binascii.rldecode_hqx()` was removed in 3.11",
+ "Restart their computer and try running it again (it worked before)",
+ (
+ "Nothing. They weren't actually getting an error, "
+ "the import was just greyed out in VSCode because they hadn't used it yet. "
+ )
+ )
+ ),
+ Question(
+ question="Why is static typing a terrible feature for a programming language?",
+ answers=(
+ "It makes it more difficult to apply polymorphism",
+ "You get TypeErrors before you can even run the code, slowing down development",
+ "Guido likes static typing now, actually",
+ "All of the above"
+ )
+ ),
+ Question(
+ question="When is Lemon Day?",
+ answers=(
+ "January 1",
+ "April 14",
+ "August 29",
+ "Any day that is not Lime Day"
+ )
+ )
+]
+
+TOTAL_QUESTION_TO_ASK = 4
+
+HELPERS_ROLE = discord.Object(Roles.new_helpers)
+
+
+def format_question(question_index: int) -> str:
+ """Format the question to be displayed in chat."""
+ question = questions[question_index]
+ prompt = f"**Question {question_index+1} of {TOTAL_QUESTION_TO_ASK}**\n\n{question.question}\n\n"
+ prompt += "\n".join(
+ f":regional_indicator_{letter}: {answer}"
+ for letter, answer in zip(ascii_lowercase, question.answers)
+ )
+ return prompt
+
+
+class HelperingView(discord.ui.View):
+ """A view that implements the helpering logic by asking a series of questions."""
+
+ def __init__(self, phase: int = 0):
+ super().__init__()
+ print(phase)
+ self.phase = phase
+
+ answers_view = AnswersSelect(phase)
+ self.add_item(answers_view)
+
+
+class AnswersSelect(discord.ui.Select):
+ """A selection of answers to the given question."""
+
+ def __init__(self, phase: int):
+ question = questions[phase]
+ answers = [discord.SelectOption(label=answer) for answer in ascii_uppercase[:len(question.answers)]]
+
+ super().__init__(options=answers)
+ self.phase = phase
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ """Move to the next question, or apply the role if enough question were answered."""
+ if self.phase + 1 >= TOTAL_QUESTION_TO_ASK:
+ if isinstance(interaction.user, discord.Member):
+ await interaction.user.add_roles(HELPERS_ROLE)
+ await interaction.response.edit_message(
+ content=":white_check_mark: Added the Helpers role!", view=None
+ )
+ else:
+ content = format_question(self.phase + 1)
+ view = HelperingView(self.phase + 1)
+ await interaction.response.edit_message(content=content, view=view)
+
+ self.view.stop()
+
+
+class HelperingButton(discord.ui.Button):
+ """The button which starts the helpering process."""
+
+ def __init__(self, assigned: bool, row: int,):
+ label = "Add role Helpers" if not assigned else "Remove role Helpers"
+ style = discord.ButtonStyle.green if not assigned else discord.ButtonStyle.red
+ super().__init__(style=style, label=label, row=row)
+ self.assigned = assigned
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ """Either remove the Helpers role or start the Helpering process."""
+ if self.assigned:
+ if isinstance(interaction.user, discord.Member):
+ await interaction.user.remove_roles(HELPERS_ROLE)
+ self.label = "Add role Helpers"
+ self.style = discord.ButtonStyle.green
+ self.assigned = not self.assigned
+ await interaction.response.edit_message(view=self.view)
+ await interaction.followup.send("Removed role Helpers", ephemeral=True)
+ return
+
+ await interaction.response.edit_message(content="Launching Helpering process, good luck!", view=None)
+ content = format_question(0)
+ view = HelperingView()
+ await interaction.followup.send(
+ content=content,
+ view=view,
+ ephemeral=True,
+ )
+ self.view.stop()
diff --git a/botstrap.py b/botstrap.py
index 90c2c2fbc..ccf6993f5 100644
--- a/botstrap.py
+++ b/botstrap.py
@@ -1,5 +1,6 @@
import os
import re
+import sys
from pathlib import Path
from dotenv import load_dotenv
@@ -15,6 +16,12 @@ env_file_path = Path(".env.server")
BOT_TOKEN = os.getenv("BOT_TOKEN", None)
GUILD_ID = os.getenv("GUILD_ID", None)
+COMMUNITY_FEATURE = "COMMUNITY"
+PYTHON_HELP_CHANNEL_NAME = "python_help"
+PYTHON_HELP_CATEGORY_NAME = "python_help_system"
+ANNOUNCEMENTS_CHANNEL_NAME = "announcements"
+RULES_CHANNEL_NAME = "rules"
+GUILD_FORUM_TYPE = 15
if not BOT_TOKEN:
message = (
@@ -33,85 +40,141 @@ if not GUILD_ID:
raise ValueError(message)
+class SilencedDict(dict):
+ """A dictionary that silences KeyError exceptions upon subscription to non existent items."""
+
+ def __init__(self, name: str):
+ self.name = name
+ super().__init__()
+
+ def __getitem__(self, item: str):
+ try:
+ return super().__getitem__(item)
+ except KeyError:
+ log.warning(f"Couldn't find key: {item} in dict: {self.name} ")
+ log.warning(
+ "Please make sure to follow our contribution guideline "
+ "https://www.pythondiscord.com/pages/guides/pydis-guides/contributing/bot/ "
+ "to guarantee a successful run of botstrap "
+ )
+ sys.exit(-1)
+
+
class DiscordClient(Client):
"""An HTTP client to communicate with Discord's APIs."""
- def __init__(self):
+ def __init__(self, guild_id: int | str):
super().__init__(
base_url="https://discord.com/api/v10",
headers={"Authorization": f"Bot {BOT_TOKEN}"},
- event_hooks={"response": [self._raise_for_status]}
+ event_hooks={"response": [self._raise_for_status]},
)
+ self.guild_id = guild_id
@staticmethod
def _raise_for_status(response: Response) -> None:
response.raise_for_status()
-
-def get_all_roles(guild_id: int | str, client: DiscordClient) -> dict:
- """Fetches all the roles in a guild."""
- result = {}
-
- response = client.get(f"guilds/{guild_id}/roles")
- roles = response.json()
-
- for role in roles:
- name = "_".join(part.lower() for part in role["name"].split(" ")).replace("-", "_")
- result[name] = role["id"]
-
- return result
-
-
-def get_all_channels_and_categories(
- guild_id: int | str,
- client: DiscordClient
-) -> tuple[dict[str, str], dict[str, str]]:
- """Fetches all the text channels & categories in a guild."""
- off_topic_channel_name_regex = r"ot\d{1}(_.*)+"
- off_topic_count = 0
- channels = {} # could be text channels only as well
- categories = {}
-
- response = client.get(f"guilds/{guild_id}/channels")
- server_channels = response.json()
-
- for channel in server_channels:
- channel_type = channel["type"]
- name = "_".join(part.lower() for part in channel["name"].split(" ")).replace("-", "_")
- if re.match(off_topic_channel_name_regex, name):
- name = f"off_topic_{off_topic_count}"
- off_topic_count += 1
-
- if channel_type == 4:
- categories[name] = channel["id"]
- else:
- channels[name] = channel["id"]
-
- return channels, categories
-
-
-def webhook_exists(webhook_id_: int, client: DiscordClient) -> bool:
- """A predicate that indicates whether a webhook exists already or not."""
- try:
- client.get(f"webhooks/{webhook_id_}")
- return True
- except HTTPStatusError:
- return False
-
-
-def create_webhook(name: str, channel_id_: int, client: DiscordClient) -> str:
- """Creates a new webhook for a particular channel."""
- payload = {"name": name}
-
- response = client.post(f"channels/{channel_id_}/webhooks", json=payload)
- new_webhook = response.json()
- return new_webhook["id"]
-
-
-with DiscordClient() as discord_client:
+ def upgrade_server_to_community_if_necessary(
+ self,
+ rules_channel_id_: int | str,
+ announcements_channel_id_: int | str,
+ ) -> None:
+ """Fetches server info & upgrades to COMMUNITY if necessary."""
+ response = self.get(f"/guilds/{self.guild_id}")
+ payload = response.json()
+
+ if COMMUNITY_FEATURE not in payload["features"]:
+ log.warning("This server is currently not a community, upgrading.")
+ payload["features"].append(COMMUNITY_FEATURE)
+ payload["rules_channel_id"] = rules_channel_id_
+ payload["public_updates_channel_id"] = announcements_channel_id_
+ self.patch(f"/guilds/{self.guild_id}", json=payload)
+ log.info(f"Server {self.guild_id} has been successfully updated to a community.")
+
+ def create_forum_channel(
+ self,
+ channel_name_: str,
+ category_id_: int | str | None = None
+ ) -> int:
+ """Creates a new forum channel."""
+ payload = {"name": channel_name_, "type": GUILD_FORUM_TYPE}
+ if category_id_:
+ payload["parent_id"] = category_id_
+
+ response = self.post(f"/guilds/{self.guild_id}/channels", json=payload)
+ forum_channel_id = response.json()["id"]
+ log.info(f"New forum channel: {channel_name_} has been successfully created.")
+ return forum_channel_id
+
+ def is_forum_channel(self, channel_id_: str) -> bool:
+ """A boolean that indicates if a channel is of type GUILD_FORUM."""
+ response = self.get(f"/channels/{channel_id_}")
+ return response.json()["type"] == GUILD_FORUM_TYPE
+
+ def delete_channel(self, channel_id_: id) -> None:
+ """Delete a channel."""
+ log.info(f"Channel python-help: {channel_id_} is not a forum channel and will be replaced with one.")
+ self.delete(f"/channels/{channel_id_}")
+
+ def get_all_roles(self) -> dict:
+ """Fetches all the roles in a guild."""
+ result = SilencedDict(name="Roles dictionary")
+
+ response = self.get(f"guilds/{self.guild_id}/roles")
+ roles = response.json()
+
+ for role in roles:
+ name = "_".join(part.lower() for part in role["name"].split(" ")).replace("-", "_")
+ result[name] = role["id"]
+
+ return result
+
+ def get_all_channels_and_categories(self) -> tuple[dict[str, str], dict[str, str]]:
+ """Fetches all the text channels & categories in a guild."""
+ off_topic_channel_name_regex = r"ot\d{1}(_.*)+"
+ off_topic_count = 0
+ channels = SilencedDict(name="Channels dictionary")
+ categories = SilencedDict(name="Categories dictionary")
+
+ response = self.get(f"guilds/{self.guild_id}/channels")
+ server_channels = response.json()
+
+ for channel in server_channels:
+ channel_type = channel["type"]
+ name = "_".join(part.lower() for part in channel["name"].split(" ")).replace("-", "_")
+ if re.match(off_topic_channel_name_regex, name):
+ name = f"off_topic_{off_topic_count}"
+ off_topic_count += 1
+
+ if channel_type == 4:
+ categories[name] = channel["id"]
+ else:
+ channels[name] = channel["id"]
+
+ return channels, categories
+
+ def webhook_exists(self, webhook_id_: int) -> bool:
+ """A predicate that indicates whether a webhook exists already or not."""
+ try:
+ self.get(f"webhooks/{webhook_id_}")
+ return True
+ except HTTPStatusError:
+ return False
+
+ def create_webhook(self, name: str, channel_id_: int) -> str:
+ """Creates a new webhook for a particular channel."""
+ payload = {"name": name}
+
+ response = self.post(f"channels/{channel_id_}/webhooks", json=payload)
+ new_webhook = response.json()
+ return new_webhook["id"]
+
+
+with DiscordClient(guild_id=GUILD_ID) as discord_client:
config_str = "#Roles\n"
- all_roles = get_all_roles(guild_id=GUILD_ID, client=discord_client)
+ all_roles = discord_client.get_all_roles()
for role_name in _Roles.__fields__:
@@ -122,10 +185,30 @@ with DiscordClient() as discord_client:
config_str += f"roles_{role_name}={role_id}\n"
- all_channels, all_categories = get_all_channels_and_categories(guild_id=GUILD_ID, client=discord_client)
+ all_channels, all_categories = discord_client.get_all_channels_and_categories()
config_str += "\n#Channels\n"
+ rules_channel_id = all_channels[RULES_CHANNEL_NAME]
+ announcements_channel_id = all_channels[ANNOUNCEMENTS_CHANNEL_NAME]
+
+ discord_client.upgrade_server_to_community_if_necessary(rules_channel_id, announcements_channel_id)
+
+ create_help_channel = True
+
+ if PYTHON_HELP_CHANNEL_NAME in all_channels:
+ python_help_channel_id = all_channels[PYTHON_HELP_CHANNEL_NAME]
+ if not discord_client.is_forum_channel(python_help_channel_id):
+ discord_client.delete_channel(python_help_channel_id)
+ else:
+ create_help_channel = False
+
+ if create_help_channel:
+ python_help_channel_name = PYTHON_HELP_CHANNEL_NAME.replace('_', '-')
+ python_help_category_id = all_categories[PYTHON_HELP_CATEGORY_NAME]
+ python_help_channel_id = discord_client.create_forum_channel(python_help_channel_name, python_help_category_id)
+ all_channels[PYTHON_HELP_CHANNEL_NAME] = python_help_channel_id
+
for channel_name in _Channels.__fields__:
channel_id = all_channels.get(channel_name, None)
if not channel_id:
@@ -135,6 +218,7 @@ with DiscordClient() as discord_client:
continue
config_str += f"channels_{channel_name}={channel_id}\n"
+ config_str += f"channels_{PYTHON_HELP_CHANNEL_NAME}={python_help_channel_id}\n"
config_str += "\n#Categories\n"
@@ -153,10 +237,10 @@ with DiscordClient() as discord_client:
config_str += "\n#Webhooks\n"
for webhook_name, webhook_model in Webhooks:
- webhook = webhook_exists(webhook_model.id, client=discord_client)
+ webhook = discord_client.webhook_exists(webhook_model.id)
if not webhook:
webhook_channel_id = int(all_channels[webhook_name])
- webhook_id = create_webhook(webhook_name, webhook_channel_id, client=discord_client)
+ webhook_id = discord_client.create_webhook(webhook_name, webhook_channel_id)
else:
webhook_id = webhook_model.id
config_str += f"webhooks_{webhook_name}__id={webhook_id}\n"