diff --git a/DiscordBot/bot.py b/DiscordBot/bot.py index ec5dddb6..89b280c5 100644 --- a/DiscordBot/bot.py +++ b/DiscordBot/bot.py @@ -6,7 +6,7 @@ import logging import re import requests -from report import Report +from report import Report, AbuseType, MisinfoCategory, HealthCategory, NewsCategory import pdb # Set up logging to the console @@ -34,6 +34,7 @@ def __init__(self): self.group_num = None self.mod_channels = {} # Map from guild to the mod channel id for that guild self.reports = {} # Map from user IDs to the state of their report + self.active_mod_flow = None # State for the current moderation flow async def on_ready(self): print(f'{self.user.name} has connected to Discord! It is these guilds:') @@ -99,33 +100,156 @@ async def handle_dm(self, message): self.reports.pop(author_id) async def handle_channel_message(self, message): - # Only handle messages sent in the "group-#" channel - if not message.channel.name == f'group-{self.group_num}': + # Only handle messages sent in the "group-#-mod" channel + if message.channel.name == f'group-{self.group_num}-mod': + await self.handle_mod_channel_message(message) + elif message.channel.name == f'group-{self.group_num}': return - # Forward the message to the mod channel - mod_channel = self.mod_channels[message.guild.id] - await mod_channel.send(f'Forwarded message:\n{message.author.name}: "{message.content}"') - scores = self.eval_text(message.content) - await mod_channel.send(self.code_format(scores)) - - - def eval_text(self, message): - '''' - TODO: Once you know how you want to evaluate messages in your channel, - insert your code here! This will primarily be used in Milestone 3. - ''' - return message - - - def code_format(self, text): - '''' - TODO: Once you know how you want to show that a message has been - evaluated, insert your code here for formatting the string to be - shown in the mod channel. - ''' - return "Evaluated: '" + text+ "'" - + async def start_moderation_flow(self, report_type, report_content, message_author, message_link=None): + # Determine the initial step based on report type + if report_type.startswith('ADVERTISING MISINFO'): + initial_step = 'advertising_done' + elif report_type.startswith('MISINFORMATION') or report_type.startswith('HEALTH MISINFO') or report_type.startswith('NEWS MISINFO'): + initial_step = 'danger_level' + else: + initial_step = 'default_done' + self.active_mod_flow = { + 'step': initial_step, + 'report_type': report_type, + 'report_content': report_content, + 'message_author': message_author, + 'message_link': message_link, + 'context': {} + } + mod_channel = None + for channel in self.mod_channels.values(): + mod_channel = channel + break + if mod_channel: + await mod_channel.send(f"A new report has been submitted:\nType: {report_type}\nContent: {report_content}\nReported user: {message_author}") + if initial_step == 'danger_level': + await mod_channel.send("What is the level of danger for this report?\n• LOW\n• MEDIUM\n• HIGH") + elif initial_step == 'advertising_done': + await mod_channel.send("Report sent to advertising team. No further action required.") + self.active_mod_flow = None + elif initial_step == 'default_done': + # Just show the report, do not prompt for reply + self.active_mod_flow = None + else: + await self.prompt_next_moderation_step(mod_channel) + + async def notify_reported_user(self, user_name, guild, outcome, explanation=None): + # Find the user object by name in the guild + user = discord.utils.get(guild.members, name=user_name) + if user: + try: + msg = f"Your message was reviewed by moderators. Outcome: {outcome}." + if explanation: + msg += f"\nReason: {explanation}" + msg += "\nIf you believe this was a mistake, you may reply to this message to appeal." + await user.send(msg) + except Exception as e: + print(f"Failed to DM user {user_name}: {e}") + + async def handle_mod_channel_message(self, message): + if not self.active_mod_flow: + return + step = self.active_mod_flow['step'] + ctx = self.active_mod_flow['context'] + content = message.content.strip().lower() + mod_channel = message.channel + report_type = self.active_mod_flow['report_type'] + report_content = self.active_mod_flow['report_content'] + reported_user_name = self.active_mod_flow['message_author'] + guild = mod_channel.guild if hasattr(mod_channel, 'guild') else None + + # Misinformation moderation flow + if step == 'advertising_done': + # Already handled + self.active_mod_flow = None + return + if step == 'danger_level': + if content not in ['low', 'medium', 'high']: + await mod_channel.send("Invalid option. Please choose:\n• LOW\n• MEDIUM\n• HIGH") + return + ctx['danger_level'] = content + if content == 'low': + await mod_channel.send("Flag post as low danger. After claim is investigated, what action should be taken on post?\n• DO NOT RECOMMEND\n• FLAG AS UNPROVEN") + self.active_mod_flow['step'] = 'low_action_on_post' + return + elif content == 'medium': + await mod_channel.send("Flag post as medium danger. After claim is investigated, what action should be taken on post?\n• REMOVE\n• RAISE\n• REPORT TO AUTHORITIES") + self.active_mod_flow['step'] = 'medium_action_on_post' + return + elif content == 'high': + await mod_channel.send("Flag post as high danger. What emergency action should be taken based on post?\n• REMOVE\n• RAISE\n• REPORT TO AUTHORITIES") + self.active_mod_flow['step'] = 'high_action_on_post' + return + if step == 'low_action_on_post': + if content == 'do not recommend': + await mod_channel.send("Post will not be recommended. Action recorded. (Update algorithm so post is not recommended.)") + await self.notify_reported_user(reported_user_name, guild, outcome="Post not recommended.") + self.active_mod_flow = None + return + elif content == 'flag as unproven': + await mod_channel.send("Post will be flagged as unproven/non-scientific. Please add explanation for why post is being flagged.") + self.active_mod_flow['step'] = 'flag_explanation' + return + else: + await mod_channel.send("Invalid option. Please choose:\n• DO NOT RECOMMEND\n• FLAG AS UNPROVEN") + return + if step == 'flag_explanation': + await mod_channel.send(f"Explanation recorded: {message.content}\nFlagged post as not proven.") + await self.notify_reported_user(reported_user_name, guild, outcome="Post flagged as unproven/non-scientific.", explanation=message.content) + self.active_mod_flow = None + return + if step == 'medium_action_on_post' or step == 'high_action_on_post': + if content == 'remove': + await mod_channel.send("Post will be removed. Please add explanation for why post is being removed.") + self.active_mod_flow['step'] = 'remove_explanation' + return + elif content == 'raise': + await mod_channel.send("Raising to higher level moderator. Report sent to higher level moderators.") + self.active_mod_flow = None + return + elif content == 'report to authorities': + await mod_channel.send("Reporting to authorities. Report sent to authorities.") + self.active_mod_flow = None + return + else: + await mod_channel.send("Invalid option. Please choose:\n• REMOVE\n• RAISE\n• REPORT TO AUTHORITIES") + return + if step == 'remove_explanation': + await mod_channel.send(f"Explanation recorded: {message.content}\nPost removed. What action should be taken on the creator of the post?\n• RECORD INCIDENT\n• TEMPORARILY MUTE\n• REMOVE USER") + ctx['remove_explanation'] = message.content + await self.notify_reported_user( + reported_user_name, + guild, + outcome="Post removed.", + explanation=ctx.get('remove_explanation', '') + ) + self.active_mod_flow['step'] = 'action_on_user' + return + if step == 'action_on_user': + if content == 'record incident': + await mod_channel.send("Incident recorded for internal use. (Add to internal incident count for user.)") + self.active_mod_flow = None + return + elif content == 'temporarily mute': + await mod_channel.send("User will be muted for 24 hours.") + self.active_mod_flow = None + return + elif content == 'remove user': + await mod_channel.send("User will be removed.") + self.active_mod_flow = None + return + else: + await mod_channel.send("Invalid option. Please choose:\n• RECORD INCIDENT\n• TEMPORARILY MUTE\n• REMOVE USER") + return + + async def prompt_next_moderation_step(self, mod_channel): + await mod_channel.send("Moderator, please review the report and respond with your decision.") client = ModBot() client.run(discord_token) \ No newline at end of file diff --git a/DiscordBot/report.py b/DiscordBot/report.py index d2bba994..fb510ab0 100644 --- a/DiscordBot/report.py +++ b/DiscordBot/report.py @@ -6,8 +6,56 @@ class State(Enum): REPORT_START = auto() AWAITING_MESSAGE = auto() MESSAGE_IDENTIFIED = auto() + AWAITING_ABUSE_TYPE = auto() + AWAITING_MISINFO_CATEGORY = auto() + AWAITING_HEALTH_CATEGORY = auto() + AWAITING_NEWS_CATEGORY = auto() REPORT_COMPLETE = auto() +class AbuseType(Enum): + BULLYING = "bullying" + SUICIDE = "suicide/self-harm" + EXPLICIT = "sexually explicit/nudity" + MISINFORMATION = "misinformation" + HATE = "hate speech" + DANGER = "danger" + +SUICIDE_VARIANTS = { + "suicide", + "self harm", + "self-harm", + "selfharm", + "suicide/self harm", + "suicide/selfharm", + "suicide/self-harm", +} + +EXPLICIT_VARIANTS = { + "explicit", + "sexually explicit", + "sexual", + "nudity", + "nude", + "sexually explicit/nudity", +} + +class MisinfoCategory(Enum): + HEALTH = "health" + ADVERTISEMENT = "advertisement" + NEWS = "news" + +class HealthCategory(Enum): + EMERGENCY = "emergency" + MEDICAL_RESEARCH = "medical research" + REPRODUCTIVE = "reproductive healthcare" + TREATMENTS = "treatments" + ALTERNATIVE = "alternative medicine" + +class NewsCategory(Enum): + HISTORICAL = "historical" + POLITICAL = "political" + SCIENCE = "science" + class Report: START_KEYWORD = "report" CANCEL_KEYWORD = "cancel" @@ -17,28 +65,24 @@ def __init__(self, client): self.state = State.REPORT_START self.client = client self.message = None - - async def handle_message(self, message): - ''' - This function makes up the meat of the user-side reporting flow. It defines how we transition between states and what - prompts to offer at each of those states. You're welcome to change anything you want; this skeleton is just here to - get you started and give you a model for working with Discord. - ''' + self.abuse_type = None + self.misinfo_category = None + self.specific_category = None - if message.content == self.CANCEL_KEYWORD: + async def handle_message(self, message): + if message.content.lower() == self.CANCEL_KEYWORD: self.state = State.REPORT_COMPLETE return ["Report cancelled."] - + if self.state == State.REPORT_START: - reply = "Thank you for starting the reporting process. " + reply = "Thank you for starting the reporting process. " reply += "Say `help` at any time for more information.\n\n" reply += "Please copy paste the link to the message you want to report.\n" reply += "You can obtain this link by right-clicking the message and clicking `Copy Message Link`." self.state = State.AWAITING_MESSAGE return [reply] - + if self.state == State.AWAITING_MESSAGE: - # Parse out the three ID strings from the message link m = re.search('/(\d+)/(\d+)/(\d+)', message.content) if not m: return ["I'm sorry, I couldn't read that link. Please try again or say `cancel` to cancel."] @@ -49,24 +93,120 @@ async def handle_message(self, message): if not channel: return ["It seems this channel was deleted or never existed. Please try again or say `cancel` to cancel."] try: - message = await channel.fetch_message(int(m.group(3))) + self.message = await channel.fetch_message(int(m.group(3))) except discord.errors.NotFound: return ["It seems this message was deleted or never existed. Please try again or say `cancel` to cancel."] + + self.state = State.AWAITING_ABUSE_TYPE + reply = "What type of abuse would you like to report?\n" + reply += "• BULLYING\n" + reply += "• SUICIDE/SELF-HARM\n" + reply += "• SEXUALLY EXPLICIT/NUDITY\n" + reply += "• MISINFORMATION\n" + reply += "• HATE SPEECH\n" + reply += "• DANGER" + return ["I found this message:", "```" + self.message.author.name + ": " + self.message.content + "```", reply] - # Here we've found the message - it's up to you to decide what to do next! - self.state = State.MESSAGE_IDENTIFIED - return ["I found this message:", "```" + message.author.name + ": " + message.content + "```", \ - "This is all I know how to do right now - it's up to you to build out the rest of my reporting flow!"] - - if self.state == State.MESSAGE_IDENTIFIED: - return [""] + if self.state == State.AWAITING_ABUSE_TYPE: + abuse_type = message.content.lower() + if abuse_type in SUICIDE_VARIANTS: + self.abuse_type = AbuseType.SUICIDE + mod_channel = self.client.mod_channels[self.message.guild.id] + await mod_channel.send(f"SUICIDE/SELF-HARM REPORT:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type="SUICIDE/SELF-HARM", + report_content=self.message.content, + message_author=self.message.author.name + ) + self.state = State.REPORT_COMPLETE + return ["Thank you for reporting. This has been sent to our moderation team for review."] - return [] + if abuse_type in EXPLICIT_VARIANTS: + self.abuse_type = AbuseType.EXPLICIT + mod_channel = self.client.mod_channels[self.message.guild.id] + await mod_channel.send(f"EXPLICIT CONTENT REPORT:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type="EXPLICIT CONTENT", + report_content=self.message.content, + message_author=self.message.author.name + ) + self.state = State.REPORT_COMPLETE + return ["Thank you for reporting. This has been sent to our moderation team for review."] - def report_complete(self): - return self.state == State.REPORT_COMPLETE - + for type in AbuseType: + if abuse_type == type.value: + self.abuse_type = type + if type == AbuseType.MISINFORMATION: + self.state = State.AWAITING_MISINFO_CATEGORY + return ["Please select the misinformation category:\n• HEALTH\n• ADVERTISEMENT\n• NEWS"] + else: + mod_channel = self.client.mod_channels[self.message.guild.id] + await mod_channel.send(f"New report - {type.value.upper()}:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type=type.value.upper(), + report_content=self.message.content, + message_author=self.message.author.name + ) + self.state = State.REPORT_COMPLETE + return ["Thank you for reporting, it has been sent to our moderation team."] + return ["Please select a valid abuse type from the list above."] + if self.state == State.AWAITING_MISINFO_CATEGORY: + category = message.content.lower() + for cat in MisinfoCategory: + if category == cat.value: + self.misinfo_category = cat + if cat == MisinfoCategory.HEALTH: + self.state = State.AWAITING_HEALTH_CATEGORY + return ["Please specify the health misinformation category:\n• EMERGENCY\n• MEDICAL RESEARCH\n• REPRODUCTIVE HEALTHCARE\n• TREATMENTS\n• ALTERNATIVE MEDICINE"] + elif cat == MisinfoCategory.NEWS: + self.state = State.AWAITING_NEWS_CATEGORY + return ["Please specify the news category:\n• HISTORICAL\n• POLITICAL\n• SCIENCE"] + else: # Advertisement + self.state = State.REPORT_COMPLETE + await self.client.mod_channels[self.message.guild.id].send(f"ADVERTISING MISINFO:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type="ADVERTISING MISINFO", + report_content=self.message.content, + message_author=self.message.author.name + ) + return ["This has been reported to our ad team."] + return ["Please select a valid misinformation category from the list above."] - + if self.state == State.AWAITING_HEALTH_CATEGORY: + health_cat = message.content.lower() + for cat in HealthCategory: + if health_cat == cat.value: + self.specific_category = cat + self.state = State.REPORT_COMPLETE + mod_channel = self.client.mod_channels[self.message.guild.id] + await mod_channel.send(f"HEALTH MISINFO - {cat.value.upper()}:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type=f"HEALTH MISINFO - {cat.value.upper()}", + report_content=self.message.content, + message_author=self.message.author.name + ) + return ["This has been sent to our moderation team."] + return ["Please select a valid health category from the list above."] + if self.state == State.AWAITING_NEWS_CATEGORY: + news_cat = message.content.lower() + for cat in NewsCategory: + if news_cat == cat.value: + self.specific_category = cat + self.state = State.REPORT_COMPLETE + mod_channel = self.client.mod_channels[self.message.guild.id] + await mod_channel.send(f"NEWS MISINFO - {cat.value.upper()}:\n{self.message.author.name}: {self.message.content}") + await self.client.start_moderation_flow( + report_type=f"NEWS MISINFO - {cat.value.upper()}", + report_content=self.message.content, + message_author=self.message.author.name + ) + return ["This has been sent to our team."] + return ["Please select a valid news category from the list above."] + + return [] + + def report_complete(self): + """Returns whether the current report is in a completed state""" + return self.state == State.REPORT_COMPLETE \ No newline at end of file