diff --git a/TODO.md b/TODO.md index 79ab7b1..fcaeff8 100644 --- a/TODO.md +++ b/TODO.md @@ -7,12 +7,6 @@ - [ ] Automatically fetch a list of all courses for the current period instead of needing to use `/course add` -## Configuration - - - [ ] Add a `/setup` command or a set of `/config {option}` commands to allow - per-server configuration of stuff like verification channels, what - courses to scrape, etc. - # Done ## General @@ -21,3 +15,9 @@ - [x] Add a github action to deploy to dockerhub or ghcr - [x] Update the docker compose file to pull from the container repository instead of building locally + +## Configuration + + - [x] Add a `/setup` command or a set of `/config {option}` commands to allow + per-server configuration of stuff like verification channels, what + courses to scrape, etc. diff --git a/alembic/versions/ce233a03ec25_create_config_table.py b/alembic/versions/ce233a03ec25_create_config_table.py new file mode 100644 index 0000000..7e85004 --- /dev/null +++ b/alembic/versions/ce233a03ec25_create_config_table.py @@ -0,0 +1,32 @@ +"""create_config_table + +Revision ID: ce233a03ec25 +Revises: 6f04bc4e4691 +Create Date: 2022-12-22 04:10:27.310547 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "ce233a03ec25" +down_revision = "6f04bc4e4691" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "config", + sa.Column("guild_id", sa.Text, primary_key=True), + sa.Column("verified_role", sa.Text, nullable=True), + sa.Column("verification_channel", sa.Text, nullable=True), + sa.UniqueConstraint("guild_id", name="unique_guild_id"), + sa.UniqueConstraint("verified_role", name="unique_verified_role"), + sa.UniqueConstraint("verification_channel", name="unique_verification_channel"), + ) + + +def downgrade() -> None: + op.drop_table("config") diff --git a/bot/bot.py b/bot/bot.py index f8ad342..11fe069 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -7,8 +7,6 @@ import redis.asyncio as redis from redis.asyncio import Redis -from bot.constants import GUILD_ID - logger = logging.getLogger("bot") @@ -25,6 +23,7 @@ async def create(cls) -> "Bot": """Create and return a new bot instance""" intents = discord.Intents.default() + intents.message_content = True intents.bans = False intents.dm_messages = False @@ -48,12 +47,6 @@ async def create(cls) -> "Bot": return cls(command_prefix="$", intents=intents, redis=redis_conn) - async def setup_hook(self): - """Sync slash commands to guild""" - - self.tree.copy_global_to(guild=GUILD_ID) - await self.tree.sync() - async def load_extensions(self) -> None: """Load all enabled extensions""" @@ -70,8 +63,6 @@ async def add_cog(self, cog: commands.Cog) -> None: await super().add_cog(cog) async def close(self) -> None: - """Close the Discord connection""" - # Remove all extensions and cogs for ext in list(self.extensions): with suppress(Exception): @@ -83,8 +74,8 @@ async def close(self) -> None: logger.info(f"Removing cog {cog}") self.remove_cog(cog) - logger.info("Closing Postgres connection") - self.pg_conn.close() + logger.info("Closing redis connection") + await self.redis.close() logger.info("Closing bot client...") diff --git a/bot/constants.py b/bot/constants.py index 9a98182..57580b0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,10 +1,8 @@ import datetime import logging -import os import textwrap import math -import discord logger = logging.getLogger("bot") @@ -18,12 +16,6 @@ SMTP_USER = secret.readline().rstrip("\n") SMTP_PASSWORD = secret.readline().rstrip("\n") -GUILD_ID = discord.Object(id=int(os.environ.get("GUILD_ID"))) - -VERIFIED_ROLE = os.environ.get("VERIFIED_ROLE") - -VERIFY_CHANNEL = os.environ.get("VERIFY_CHANNEL") - DRIVE_LINKS = { "Grondslagen van de Psychologie": "https://drive.google.com/drive/folders/10ZKbgdHg49_DRjH5TtPtZ7sZjQt6GmMD?usp=sharing", "Kwalitatieve Data Analyse": "https://drive.google.com/drive/folders/10EKAngz_VfQzSQ1RKgZbvKOZ61SmEmMZ?usp=sharing", diff --git a/bot/extensions/config.py b/bot/extensions/config.py new file mode 100644 index 0000000..0d96a84 --- /dev/null +++ b/bot/extensions/config.py @@ -0,0 +1,62 @@ +from discord import app_commands, Interaction, Role, TextChannel +from discord.ext import commands +from discord.ext.commands import Cog, command, Context +import logging + +from bot.bot import Bot +from bot.models.config import Config as ConfigModel +from bot import constants + + +logger = logging.getLogger("bot") + + +class Config(Cog): + config_group = app_commands.Group(name="config", description="bot configuration") + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @command(name="freud_sync") + @commands.guild_only() + @commands.is_owner() + async def sync(self, ctx: Context): + ctx.bot.tree.copy_global_to(guild=ctx.guild) + synced = await ctx.bot.tree.sync(guild=ctx.guild) + + logger.info(f"synced {len(synced)} commands to {ctx.guild.name}") + await ctx.reply(f"synced {len(synced)} commands to the current guild") + + @app_commands.guild_only() + @config_group.command( + name="verified_role", + description="Set the role to be applied to members once they have been verified", + ) + @app_commands.describe(role="The role to be applied") + async def set_verified_role(self, ia: Interaction, role: Role): + guild_config = await ConfigModel.get_or_create(ia.guild_id) + + guild_config.verified_role = str(role.id) + await guild_config.save() + + logger.info(f"set verified role to {role.id} for guild {ia.guild_id}") + await ia.response.send_message(f"set verified role to <@&{role.id}>") + + @app_commands.guild_only() + @config_group.command( + name="verification_channel", + description="Set the channel in which the /verify command can be used", + ) + @app_commands.describe(channel="The channel to select") + async def set_verification_channel(self, ia: Interaction, channel: TextChannel): + guild_config = await ConfigModel.get_or_create(ia.guild_id) + + guild_config.verification_channel = str(channel.id) + await guild_config.save() + + logger.info(f"set verification channel to {channel.id} for guild {ia.guild_id}") + await ia.response.send_message(f"set verification channel to <#{channel.id}>") + + +async def setup(bot: Bot): + await bot.add_cog(Config(bot)) diff --git a/bot/extensions/verify.py b/bot/extensions/verify.py index 1609f7a..86953b9 100644 --- a/bot/extensions/verify.py +++ b/bot/extensions/verify.py @@ -11,12 +11,13 @@ from bot import constants from bot.bot import Bot from bot.models.profile import Profile +from bot.models.config import Config EMAIL_REGEX = re.compile(r"^[^\s@]+@ugent\.be$") CODE_REGEX = re.compile(r"^['|<]?([a-z0-9]{32})[>|']?$") -EMAIL_MESSAGE = "From: psychology.ugent@gmail.com\nTo: {to}\nSubject: Psychology Discord Verification Code\n\nYour verification code for the psychology discord server is '{code}'" +EMAIL_MESSAGE = "From: {from_}\nTo: {to}\nSubject: Psychology Discord Verification Code\n\nYour verification code for the psychology discord server is '{code}'" logger = logging.getLogger("bot") @@ -29,7 +30,7 @@ def __init__(self, bot: Bot) -> None: @staticmethod def send_confirmation_email(to: str, code: str): - message = EMAIL_MESSAGE.format(to=to, code=code) + message = EMAIL_MESSAGE.format(from_=constants.SMTP_USER, to=to, code=code) email_logger.info(f"sending email to {to}...") server = smtplib.SMTP("smtp.gmail.com", 587) @@ -141,19 +142,34 @@ async def verify_code(self, iactn: Interaction, code: str): return - profile.confirmation_code = None - await profile.save() - user = iactn.user logger.info(f"[{author_id}] {iactn.user.name} verified succesfully") - await user.add_roles( - discord.utils.get(user.guild.roles, name=constants.VERIFIED_ROLE) - ) + + config = await Config.get(iactn.guild_id) + if config is None: + logger.error(f"no config for guild {iactn.guild_id} exists yet") + await iactn.response.send_message( + "The bot has not been set up properly yet, please notify a server admin" + ) + return + + verified_role = config.verified_role + if verified_role is None: + logger.error(f"no verified role for guild {iactn.guild_id} exists yet") + await iactn.response.send_message( + "The bot has not been set up properly yet, please notify a server admin" + ) + return + + await user.add_roles(discord.utils.get(user.guild.roles, id=int(verified_role))) await iactn.response.send_message( - "You have verified succesfully! Welcome to the psychology server" + "You have verified succesfully! Welcome to the server" ) + profile.confirmation_code = None + await profile.save() + @app_commands.command( name="verify", description="Verify that you are a true UGentStudent" ) @@ -161,9 +177,27 @@ async def verify_code(self, iactn: Interaction, code: str): async def verify(self, iactn: Interaction, argument: str): author_id = iactn.user.id - if str(iactn.channel_id) != constants.VERIFY_CHANNEL: + config = await Config.get(iactn.guild_id) + if config is None: + logger.error(f"no config for guild {iactn.guild_id} exists yet") + await iactn.response.send_message( + "The bot has not been set up properly yet, please notify a server admin" + ) + return + + verification_channel = config.verification_channel + if verification_channel is None: + logger.error( + f"no verification channel for guild {iactn.guild_id} exists yet" + ) + await iactn.response.send_message( + "The bot has not been set up properly yet, please notify a server admin" + ) + return + + if str(iactn.channel_id) != verification_channel: await iactn.response.send_message( - f"This command can only be used in <#{constants.VERIFY_CHANNEL}>", + f"This command can only be used in <#{verification_channel}>", ephemeral=True, ) return diff --git a/bot/models/__init__.py b/bot/models/__init__.py index 1262be7..cd146e9 100644 --- a/bot/models/__init__.py +++ b/bot/models/__init__.py @@ -16,13 +16,16 @@ class Model: @classmethod - async def create(cls, **kwargs): + async def create(cls, **kwargs) -> "Model": """Create a new row""" async with session_factory() as session: - session.add(cls(**kwargs)) + instance = cls(**kwargs) + session.add(instance) await session.commit() + return instance + async def save(self): """Save an updated row""" diff --git a/bot/models/config.py b/bot/models/config.py new file mode 100644 index 0000000..2080fa2 --- /dev/null +++ b/bot/models/config.py @@ -0,0 +1,52 @@ +import logging +from typing import Optional +from sqlalchemy import Column, Text +from sqlalchemy.future import select +from sqlalchemy.orm import Query + +from bot.models import Base, Model, session_factory + + +logger = logging.getLogger("models") + + +class Config(Base, Model): + __tablename__ = "config" + + guild_id = Column(Text, primary_key=True) + verified_role = Column(Text, unique=True, nullable=False) + verification_channel = Column(Text, unique=True, nullable=False) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}> guild_id: {self.guild_id} verified_role: {self.verified_role} verification_channel: {self.verification_channel}" + + @classmethod + async def get(cls, id_: int) -> Optional["Config"]: + """Find a config given its guild ID""" + + async with session_factory() as session: + result: Query = await session.execute( + select(cls).where(cls.guild_id == str(id_)) + ) + + r = result.first() + if r is None: + return None + else: + return r[0] + + @classmethod + async def get_or_create(cls, id_: int) -> "Config": + """Find a config given its guild ID, or create an empty config if it does not exist""" + + async with session_factory() as session: + result: Query = await session.execute( + select(cls).where(cls.guild_id == str(id_)) + ) + + r = result.first() + if r is None: + logger.info(f"created new config for guild {id_}") + return await Config.create(guild_id=str(id_), verified_role=None) + else: + return r[0] diff --git a/docker-compose.yaml b/docker-compose.yaml index 611bb30..6ed6142 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,10 +10,6 @@ services: - CACHE_HOST=freud_bot_cache - CACHE_PORT=6379 - - - GUILD_ID=983088370509053983 - - VERIFY_CHANNEL=983088370966212609 - - VERIFIED_ROLE=Verified logging: options: max-size: "4096m" @@ -34,10 +30,6 @@ services: - CACHE_HOST=freud_bot_cache - CACHE_PORT=6379 - - - GUILD_ID=983088370509053983 - - VERIFY_CHANNEL=983088370966212609 - - VERIFIED_ROLE=Verified logging: options: max-size: "4096m"