diff --git a/docs/configuration-guide.md b/docs/configuration-guide.md index 4b97f0a..8698b1d 100644 --- a/docs/configuration-guide.md +++ b/docs/configuration-guide.md @@ -39,6 +39,7 @@ This section aims to document all of the different fields in the `config.json` f | `verify.discord_minimum_account_age_days` | The minimum age of a Discord account, in days, before the user is allowed to link their accounts. | | `verify.reddit_minimum_account_age_days` | The minimum age of a Reddit account, in days, before the user is allowed to link their accounts. | | `verify.token_prefix` | The text that prefixes the verification token sent to the user when verifying their Reddit account. | +| `feedback.feedback_channel_id` | The text channel to send new feedback requests to. | ### Roles Configuration diff --git a/docs/images/cex.png b/docs/images/cex.png new file mode 100644 index 0000000..395621e Binary files /dev/null and b/docs/images/cex.png differ diff --git a/docs/images/feedback-step1.png b/docs/images/feedback-step1.png new file mode 100644 index 0000000..b905cba Binary files /dev/null and b/docs/images/feedback-step1.png differ diff --git a/docs/images/feedback-step2.png b/docs/images/feedback-step2.png new file mode 100644 index 0000000..86c5188 Binary files /dev/null and b/docs/images/feedback-step2.png differ diff --git a/docs/user-guide.md b/docs/user-guide.md index 4dbe829..eeb6050 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -71,6 +71,26 @@ Allows a user to search recently sold items on eBay to get an idea of how to pri ![A screenshot showing the result of the `/ebay` slash command on the Harmony Discord bot](images/ebay.png) +### `/cex` + +- **Who can use this:** Any user. + +Allows a user to search items on CeX UK to get an idea of how to price any items they wish to sell. + +`/cex` takes one argument, the item to search for, and produces results based on the API's response to your query: + +![A screenshot showing the result of the `/cex` slash command on the Harmony Discord bot](images/cex.png) + +### `/feedback` + +- **Who can use this:** Any user. + +Allows a user to give feedback for other members of the server to vote on. + +![A screenshot showing the modal dialog that appears when you initiate a feedback request](images/feedback-step1.png) + +![A screenshot showing the modal dialog that appears when you complete a feedback request](images/feedback-step2.png) + ## App Commands These commands can be run by right-clicking on a user or message, and selecting the Apps submenu. diff --git a/harmony_cogs/ebay.py b/harmony_cogs/ebay.py index b636ebd..4f2bd24 100644 --- a/harmony_cogs/ebay.py +++ b/harmony_cogs/ebay.py @@ -1,6 +1,7 @@ import bs4 import json import httpx +import typing import discord import statistics import urllib.parse @@ -39,7 +40,7 @@ def __init__(self, bot: commands.Bot): ) @app_commands.guild_only @app_commands.guilds(discord.Object(int(config["discord"]["guild_id"]))) - async def ebay(self, interaction: discord.Interaction, search_query: str) -> None: + async def ebay(self, interaction: discord.Interaction, search_query: str) -> typing.NoReturn: """ Method invoked when the user performs the eBay search slash command. :param interaction: The interaction to use to send messages. diff --git a/harmony_cogs/feedback.py b/harmony_cogs/feedback.py new file mode 100644 index 0000000..d253797 --- /dev/null +++ b/harmony_cogs/feedback.py @@ -0,0 +1,54 @@ +import json +import typing +import discord + +import harmony_services.db +import harmony_ui.feedback + +from loguru import logger +from discord import app_commands +from discord.ext import commands + + +with open("config.json", "r") as f: + config = json.load(f) + +feedback_channel_id = int(config["feedback"]["feedback_channel_id"]) +discord_guild_id = int(config["discord"]["guild_id"]) + + +class Feedback(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + self.feedback_channel = bot.get_guild(discord_guild_id).get_channel(feedback_channel_id) + if not self.feedback_channel: + logger.error(f"Feedback channel with ID {feedback_channel_id} doesn't exist.") + raise RuntimeError() + + @app_commands.command( + name='feedback', + description='Create feedback items for the community to vote on.' + ) + @app_commands.guild_only + @app_commands.guilds(discord.Object(int(config["discord"]["guild_id"]))) + async def feedback(self, interaction: discord.Interaction) -> typing.NoReturn: + """ + Method invoked when the user performs the feedback slash command. + :param interaction: The interaction to use to respond to the user. + :return: Nothing. + """ + await interaction.response.send_modal(harmony_ui.feedback.CreateFeedbackItemModal(self.feedback_channel)) + + @commands.Cog.listener() + async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> typing.NoReturn: + """ + Delete feedback data when the message containing its voting view is deleted. + :param payload: The data about which message was deleted. + :return: Nothing. + """ + logger.info(f"Deleting feedback data because message with ID {payload.message_id} " + f"was deleted from #{self.feedback_channel.name}") + + if payload.channel_id == self.feedback_channel.id: + harmony_services.db.delete_feedback_data(payload.message_id) \ No newline at end of file diff --git a/harmony_cogs/verify.py b/harmony_cogs/verify.py index c841b59..c92a650 100644 --- a/harmony_cogs/verify.py +++ b/harmony_cogs/verify.py @@ -1,6 +1,6 @@ import json +import typing import discord - import harmony_ui import harmony_ui.verify @@ -20,7 +20,7 @@ class Verify(commands.Cog): - def __init__(self, bot: commands.Bot) -> None: + def __init__(self, bot: commands.Bot) -> typing.NoReturn: self.bot = bot whois_context_menu = app_commands.ContextMenu( @@ -41,7 +41,7 @@ def __init__(self, bot: commands.Bot) -> None: check_reddit_accounts_task.start(self.bot) check_discord_roles_task.start(self.bot) - def cog_unload(self) -> None: + def cog_unload(self) -> typing.NoReturn: check_reddit_accounts_task.cancel() check_discord_roles_task.cancel() @@ -51,7 +51,7 @@ def cog_unload(self) -> None: ) @app_commands.guild_only @app_commands.guilds(discord.Object(int(config["discord"]["guild_id"]))) - async def display_verification_dialog(self, interaction: discord.Interaction) -> None: + async def display_verification_dialog(self, interaction: discord.Interaction) -> typing.NoReturn: """ Command to display the verification model, to allow users to verify their Reddit accounts. :param interaction: The interaction context for this command. @@ -185,5 +185,5 @@ async def display_whois_result(self, interaction: discord.Interaction, member: d await interaction.response.send_message(embed=embed, ephemeral=True) -async def setup(bot: commands.Bot) -> None: +async def setup(bot: commands.Bot) -> typing.NoReturn: await bot.add_cog(Verify(bot)) diff --git a/harmony_models/feedback.py b/harmony_models/feedback.py new file mode 100644 index 0000000..2bd136d --- /dev/null +++ b/harmony_models/feedback.py @@ -0,0 +1,17 @@ +import datetime +import mongoengine + + +class FeedbackVote(mongoengine.EmbeddedDocument): + discord_username = mongoengine.StringField(required=True) + vote_timestamp = mongoengine.DateTimeField(default=datetime.datetime.utcnow) + vote_weight = mongoengine.IntField(required=True, max_value=1, min_value=-1) + + +class FeedbackItem(mongoengine.Document): + author_username = mongoengine.StringField(required=True) + creation_timestamp = mongoengine.DateTimeField(default=datetime.datetime.utcnow) + feedback_title = mongoengine.StringField(required=True, max_length=200) + feedback_description = mongoengine.StringField(required=True, max_length=1500) + discord_message_id = mongoengine.LongField(required=True, unique=True) + votes = mongoengine.EmbeddedDocumentListField(FeedbackVote, default=[]) diff --git a/harmony_services/db.py b/harmony_services/db.py index 9967497..aca6a2a 100644 --- a/harmony_services/db.py +++ b/harmony_services/db.py @@ -3,6 +3,7 @@ import mongoengine import harmony_models.verify as verify_models +import harmony_models.feedback as feedback_models with open("config.json", "r") as f: config = json.load(f) @@ -77,3 +78,24 @@ def has_verification_data(discord_user_id: int) -> bool: :return: True if the user has verification data, otherwise False. """ return get_verification_data(discord_user_id=discord_user_id) is not None + + +def get_feedback_data(message_id: int) -> typing.Optional[feedback_models.FeedbackItem]: + """ + Fetch feedback data by the message ID containing its voting view. + :param message_id: The message ID to fetch. + :return: The feedback item, if it exists. + """ + return feedback_models.FeedbackItem.objects(discord_message_id=message_id).first() + + +def delete_feedback_data(message_id: int) -> typing.NoReturn: + """ + Delete feedback data by the message ID containing its voting view. + :param message_id: The feedback data to delete, by the Discord message ID containing its voting view. + :return: Nothing. + """ + feedback_data = get_feedback_data(message_id) + + if feedback_data: + feedback_data.delete() diff --git a/harmony_services/reddit.py b/harmony_services/reddit.py index 8055eab..c67f49f 100644 --- a/harmony_services/reddit.py +++ b/harmony_services/reddit.py @@ -22,7 +22,7 @@ verification_message_template = None -def load_verification_message_template() -> None: +def load_verification_message_template() -> typing.NoReturn: """ Load the verification message template as markdown, and verify that it has the correct template variables. :return: Nothing. @@ -150,7 +150,7 @@ def send_verification_message( verification_code: str, subreddit_name: str, guild_name: str -) -> None: +) -> typing.NoReturn: """ Send a message to the user with their verification code in it. :param username: The user to send the message to. @@ -175,7 +175,7 @@ def send_verification_message( redditor.message(subject="Your /r/HardwareSwapUK Discord verification code", message=message_contents) -def update_user_flair(username: str, flair_text: str, css_class_name: str) -> None: +def update_user_flair(username: str, flair_text: str, css_class_name: str) -> typing.NoReturn: """ Update a user's flair. :param username: The username of the user whose flair should be updated. diff --git a/harmony_ui/__init__.py b/harmony_ui/__init__.py index 18aaeeb..f0c9c44 100644 --- a/harmony_ui/__init__.py +++ b/harmony_ui/__init__.py @@ -1,12 +1,13 @@ import random import string -import traceback +import typing import discord +import traceback from loguru import logger -async def handle_error(interaction: discord.Interaction, error: Exception) -> None: +async def handle_error(interaction: discord.Interaction, error: Exception) -> typing.NoReturn: """ Handle an exception encountered during an interaction. :param interaction: The interaction in which the exception was raised. diff --git a/harmony_ui/cex.py b/harmony_ui/cex.py index eb510b1..de0b1d9 100644 --- a/harmony_ui/cex.py +++ b/harmony_ui/cex.py @@ -13,7 +13,7 @@ def __init__( original_interaction: discord.Interaction, original_search_query: str ): - super().__init__() + super().__init__(timeout=None) self.original_interaction = original_interaction self.current_result_index = 0 diff --git a/harmony_ui/feedback.py b/harmony_ui/feedback.py new file mode 100644 index 0000000..c371539 --- /dev/null +++ b/harmony_ui/feedback.py @@ -0,0 +1,232 @@ +import typing +import discord +import harmony_ui +import mongoengine +import harmony_services.db +import harmony_models.feedback + + +class FeedbackTitleField(discord.ui.TextInput): + def __init__(self): + super().__init__( + label='Feedback title', + required=True, + max_length=200 + ) + + +class FeedbackDescriptionField(discord.ui.TextInput): + def __init__(self): + super().__init__( + label='Feedback description', + required=True, + max_length=1500, + style=discord.TextStyle.long + ) + + +class FeedbackItemView(discord.ui.View): + upvote_weight = 1 + downvote_weight = -1 + + def __init__(self): + """ + Create the FeedbackItemView which allows users to vote on feedback items. + """ + super().__init__(timeout=None) + + @discord.ui.button(label="Upvote", style=discord.ButtonStyle.green, row=1, custom_id="feedback_upvote_button") + async def upvote(self, interaction: discord.Interaction, _: discord.ui.Button) -> typing.NoReturn: + await self.process_vote(interaction, self.upvote_weight) + + @discord.ui.button(label="Downvote", style=discord.ButtonStyle.red, row=1, custom_id="feedback_downvote_button") + async def downvote(self, interaction: discord.Interaction, _: discord.ui.Button) -> typing.NoReturn: + await self.process_vote(interaction, self.downvote_weight) + + async def process_vote( + self, + interaction: discord.Interaction, + new_vote_weight: int + ) -> typing.NoReturn: + """ + Process a user vote. + :param interaction: The interaction that triggered the vote. + :param new_vote_weight: The new vote weight. + :return: Nothing. + """ + feedback_item = harmony_services.db.get_feedback_data(interaction.message.id) + + # Is the user trying to vote on their own feedback? + if feedback_item.author_username == interaction.user.name: + await interaction.response.send_message( + ":no_entry_sign: You can't vote on your own feedback.", + ephemeral=True + ) + + return + + # Has the user already voted? + vote = self.get_vote(interaction.user.name, feedback_item) + + if vote: + # If they're trying to vote in the same direction as before, then don't let them. + if vote.vote_weight == new_vote_weight: + await interaction.response.send_message( + ":no_entry_sign: Looks like you've already voted on this feedback - you can only vote once.", + ephemeral=True + ) + + return + # Otherwise, update their existing vote to point the other way. + else: + vote.vote_weight = new_vote_weight + feedback_item.save() + + await interaction.response.send_message( + ":inbox_tray: Your vote has been updated.", + ephemeral=True + ) + + await self.update_view(interaction, feedback_item) + + return + + feedback_item.votes.create( + discord_username=interaction.user.name, + vote_weight=new_vote_weight + ) + + feedback_item.save() + + await interaction.response.send_message( + ":inbox_tray: Your vote has been cast.", + ephemeral=True + ) + + await self.update_view(interaction, feedback_item) + + async def update_view(self, interaction: discord.Interaction, feedback_item: harmony_models.feedback.FeedbackItem): + upvote_count = ( + sum([vote.vote_weight + for vote in feedback_item.votes + if vote.vote_weight == self.upvote_weight])) + + downvote_count = ( + abs(sum([vote.vote_weight + for vote in feedback_item.votes + if vote.vote_weight == self.downvote_weight]))) + + await interaction.message.edit( + embed=create_feedback_embed( + feedback_item.feedback_title, + feedback_item.feedback_description, + feedback_item.author_username, + upvote_count, + downvote_count + ), + view=self + ) + + @staticmethod + def get_vote(username: str, feedback_item: harmony_models.feedback.FeedbackItem) \ + -> typing.Optional[harmony_models.feedback.FeedbackVote]: + """ + Get a user's vote on an item. + :param username: The Discord username of the user to get the vote for. + :param feedback_item: The item to query for votes. + :return: The vote if a user has already voted, otherwise None. + """ + try: + return feedback_item.votes.get(discord_username=username) + except mongoengine.DoesNotExist: + return None + + +class CreateFeedbackItemModal(discord.ui.Modal): + feedback_title_field = FeedbackTitleField() + feedback_description_field = FeedbackDescriptionField() + + def __init__(self, feedback_channel: discord.TextChannel): + super().__init__(title='Create a feedback item') + + self.feedback_channel = feedback_channel + + async def on_submit(self, interaction: discord.Interaction) -> typing.NoReturn: + await interaction.response.defer(ephemeral=True, thinking=True) + + message = await self.feedback_channel.send(":crystal_ball: I predict a new feedback item...") + + # Save the feedback item to the database + feedback_item = harmony_models.feedback.FeedbackItem( + author_username=interaction.user.name, + feedback_title=self.feedback_title_field.value, + feedback_description=self.feedback_description_field.value, + discord_message_id=message.id + ) + + feedback_item.save() + + await message.edit( + content=None, + embed=create_feedback_embed( + feedback_title=feedback_item.feedback_title, + feedback_description=feedback_item.feedback_description, + feedback_author=interaction.user.name, + feedback_upvotes=0, + feedback_downvotes=0 + ), + view=FeedbackItemView() + ) + + await interaction.followup.send(":inbox_tray: Thanks! Your feedback has been created.") + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> typing.NoReturn: + await harmony_ui.handle_error(interaction, error) + + +def create_feedback_embed( + feedback_title: str, + feedback_description: str, + feedback_author: str, + feedback_upvotes: int, + feedback_downvotes: int +) -> discord.Embed: + vote_score: str = format_vote_score(feedback_upvotes, feedback_downvotes) + embed_color: int = get_embed_color(feedback_upvotes, feedback_downvotes) + + return discord.Embed( + title=f"Feedback from {feedback_author}: {feedback_title}", + description=feedback_description, + color=embed_color + ).set_footer(text=f"This feedback has a score of {vote_score} " + f"({feedback_upvotes} upvotes, {feedback_downvotes} downvotes)") + + +def format_vote_score(feedback_upvotes: int, feedback_downvotes: int) -> str: + """ + Calculate and format the total vote score, adding a leading sign no matter the value. + :param feedback_upvotes: The number of upvotes. + :param feedback_downvotes: The number of downvotes. + :return: The formatted total score, with a leading sign. + """ + total_score = feedback_upvotes - feedback_downvotes + sign = "+" if total_score > 0 else "±" if total_score == 0 else "" + + return f"{sign}{total_score}" + + +def get_embed_color(feedback_upvotes: int, feedback_downvotes: int) -> int: + """ + Get the embed color based on the current vote score. + :param feedback_upvotes: The number of upvotes. + :param feedback_downvotes: The number of downvotes. + :return: The color, as a 24-bit hex value. + """ + total_score = feedback_upvotes - feedback_downvotes + + if total_score > 0: + return 0x40BD63 + elif total_score < 0: + return 0xBD404D + else: + return 0x40AABD \ No newline at end of file diff --git a/main.py b/main.py index 057e0f4..51afe73 100644 --- a/main.py +++ b/main.py @@ -1,28 +1,30 @@ import os import json import typing - import discord -from discord.ext import commands -from loguru import logger +import harmony_ui.feedback -from harmony_cogs.verify import Verify +from loguru import logger +from discord.ext import commands from harmony_cogs.ebay import Ebay +from harmony_cogs.verify import Verify from harmony_cogs.cex import CexSearch +from harmony_cogs.feedback import Feedback with open('config.json', 'r') as f: config = json.load(f) -TEST_GUILD = discord.Object(config["discord"]["guild_id"]) - class HarmonyBot(commands.Bot): - def __init__(self) -> None: + def __init__(self) -> typing.NoReturn: intents = discord.Intents.default() intents.members = True intents.message_content = True super().__init__(intents=intents, command_prefix="$") + async def setup_hook(self) -> typing.NoReturn: + self.add_view(harmony_ui.feedback.FeedbackItemView()) + async def on_ready(self): logger.info(f'Logged in as {self.user} (ID: {self.user.id})') logger.info('------') @@ -39,6 +41,7 @@ async def on_ready(self): await self.add_cog(Verify(self)) await self.add_cog(Ebay(self)) await self.add_cog(CexSearch(self)) + await self.add_cog(Feedback(self)) bot = HarmonyBot() @@ -51,7 +54,14 @@ async def sync( ctx: discord.ext.commands.Context, guilds: discord.ext.commands.Greedy[discord.Object], spec: typing.Optional[typing.Literal["guild", "global", "force_guild"]] = None -) -> None: +) -> typing.NoReturn: + """ + Command to update the slash commands either globally, on the current guild, or on a specified set of guilds. + :param ctx: The command context. + :param guilds: The guilds to update (optional). + :param spec: The type of update to execute. + :return: + """ if not guilds: if spec == "guild": synced = await ctx.bot.tree.sync(guild=ctx.guild)