diff options
| -rw-r--r-- | bot/constants.py | 4 | ||||
| -rw-r--r-- | bot/exts/info/subscribe.py | 4 | ||||
| -rw-r--r-- | bot/exts/recruitment/helper_utils.py | 76 | ||||
| -rw-r--r-- | bot/helper_questions.py | 144 | ||||
| -rw-r--r-- | botstrap.py | 220 | 
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" | 
