diff --git a/herring/puzzles/discordbot.py b/herring/puzzles/discordbot.py index c400ea7..71c540e 100644 --- a/herring/puzzles/discordbot.py +++ b/herring/puzzles/discordbot.py @@ -56,7 +56,10 @@ # making this less than 25 leaves a little elbow room for cleanup_channels if necessary PUZZLES_PER_CATEGORY = 20 +# special emojis used for on_raw_reaction_add SIGNUP_EMOJI = "\N{RAISED HAND}" +LEAVE_EMOJI = "\N{LEAF FLUTTERING IN WIND}" +TRIUMPH_EMOJI = "\N{PARTY POPPER}" # this is the most users we'll try to mention during an hb!who; more than this and you just get the number MAX_USER_LIST = 10 @@ -223,12 +226,39 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): # ignore myself if payload.user_id == self.bot.user.id: return - if payload.channel_id == self.announce_channel.id and payload.emoji.name == SIGNUP_EMOJI: + channel = self.bot.get_channel(payload.channel_id) or await self.bot.fetch_channel(payload.channel_id) + message: discord.Message = await channel.fetch_message(payload.message_id) + if payload.emoji.name == SIGNUP_EMOJI: # add someone to the puzzle - message: discord.Message = await self.announce_channel.fetch_message(payload.message_id) - await message.remove_reaction(SIGNUP_EMOJI, payload.member) - target_channel = message.channel_mentions[0] - await self.add_user_to_puzzle(payload.member, target_channel.name) + # we're ok with this working anywhere for any reason if anyone ever mentions a puzzle channel + if len(message.raw_channel_mentions) > 0: + target_channel_id = message.raw_channel_mentions[0] + target_channel = self.guild.get_channel(target_channel_id) + if not target_channel: + return + try: + puzzle = await _get_puzzle_by_slug(target_channel.name) + except Puzzle.DoesNotExist: + return + + logging.info(f"adding {payload.member.name} to {puzzle.slug}") + _, changed = await self.add_user_to_puzzle(payload.member, target_channel.name) + if changed and channel.type != discord.ChannelType.private: + await message.remove_reaction(SIGNUP_EMOJI, payload.member) + elif payload.emoji.name == LEAVE_EMOJI: + # this should only work on the appropriate message in the puzzle channel or in puzzle-announcements + if message.author.id == self.bot.user.id: + if payload.channel_id == self.announce_channel.id: + target_channel = message.channel_mentions[0] + elif TRIUMPH_EMOJI in message.content: + # hopefully this is the "puzzle solved!" message + target_channel = channel + else: + target_channel = None + if target_channel: + await self.remove_user_from_puzzle(payload.member, target_channel.name) + if channel.type != discord.ChannelType.private: + await message.remove_reaction(LEAVE_EMOJI, payload.member) @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -863,6 +893,7 @@ def get_channel_pair(self, puzzle_name): return text_channel, voice_channel async def remove_user_from_puzzle(self, member: discord.Member, puzzle_name: str): + if member.bot: return text_channel, voice_channel = self.get_channel_pair(puzzle_name) await text_channel.set_permissions(member, overwrite=None) @@ -1069,8 +1100,8 @@ def __init__(self, *args, **kwargs): # we don't want this bot to respond to messages; just don't ask for the intent intents.message_content = False super(HerringAnnouncerBot, self).__init__(*args, intents=intents, **kwargs) - self.guild = None - self.announce_channel = None + self.guild : Optional[discord.Guild] = None + self.announce_channel : Optional[discord.TextChannel] = None self._really_ready = asyncio.Event() async def on_ready(self): @@ -1143,7 +1174,7 @@ async def make_category(): round, category = await ensure_category_ready() text_channel, voice_channel = await _make_puzzle_channels_inner(category, puzzle) - announcement = await self.announce_channel.send(f"New puzzle {puzzle.name} opened! {SIGNUP_EMOJI} this message to join, then click here to jump to the channel: {text_channel.mention}.") + announcement = await self.announce_channel.send(f"New puzzle {puzzle.name} opened in round {round.name}! {SIGNUP_EMOJI} this message to join, then click here to jump to the channel: {text_channel.mention}.") await announcement.add_reaction(SIGNUP_EMOJI) async def post_message(self, channel_name, message, **kwargs): @@ -1154,16 +1185,20 @@ async def post_message(self, channel_name, message, **kwargs): return await channel.send(message, **kwargs) - async def post_local_and_global(self, puzzle_name, local_message, global_message:str): + async def post_local_and_global(self, puzzle_name, local_content, global_content:str, local_reaction=None, global_reaction=None): await self._really_ready.wait() channel: discord.TextChannel = get(self.guild.text_channels, name=puzzle_name) if channel is None: logging.error(f"Couldn't get Discord channel {puzzle_name} in post_local_and_global!") return - await channel.send(local_message) - global_message = global_message.replace(f"#{puzzle_name}", channel.mention) - await self.announce_channel.send(global_message) + local_message = await channel.send(local_content) + global_content = global_content.replace(f"#{puzzle_name}", channel.mention) + global_message = await self.announce_channel.send(global_content) + if local_reaction: + await local_message.add_reaction(local_reaction) + if global_reaction: + await global_message.add_reaction(global_reaction) async def add_user_to_puzzle(self, user_profile: UserProfile, puzzle_name): await self._really_ready.wait() @@ -1289,6 +1324,7 @@ def _get_puzzle_by_slug(slug): return Puzzle.objects.get(slug=slug, hunt_id=settings.HERRING_HUNT_ID) async def _add_user_to_channels(member, text_channel:discord.TextChannel, voice_channel): + if member.bot: return False current_perms = text_channel.overwrites if member not in current_perms: await text_channel.set_permissions(member, read_messages=True) diff --git a/herring/puzzles/tasks.py b/herring/puzzles/tasks.py index 2f54198..d9b5bfd 100644 --- a/herring/puzzles/tasks.py +++ b/herring/puzzles/tasks.py @@ -11,7 +11,7 @@ import json import kombu.exceptions from lazy_object_proxy import Proxy as lazy_object -from puzzles.discordbot import run_listener_bot, DISCORD_ANNOUNCER, do_in_discord +from puzzles.discordbot import run_listener_bot, DISCORD_ANNOUNCER, do_in_discord, LEAVE_EMOJI, TRIUMPH_EMOJI from puzzles.models import Puzzle, Round, UserProfile from puzzles.spreadsheets import check_spreadsheet_service, iterate_changes, make_sheet from redis import Redis @@ -72,10 +72,10 @@ def apply_async(*args, **kwargs): return t -def post_local_and_global(local_channel, local_message, global_message): - logging.warning("tasks: post_local_and_global(%s, %s, %s)", local_channel, local_message, global_message) +def post_local_and_global(local_channel, local_message, global_message, local_reaction=None, global_reaction=None): + logging.warning("tasks: post_local_and_global(%s, %s, %s, %s, %s)", local_channel, local_message, global_message, local_reaction, global_reaction) if settings.HERRING_ACTIVATE_DISCORD: - do_in_discord(DISCORD_ANNOUNCER.post_local_and_global(local_channel, local_message, global_message)) + do_in_discord(DISCORD_ANNOUNCER.post_local_and_global(local_channel, local_message, global_message, local_reaction, global_reaction)) @optional_task @shared_task(rate_limit=0.5) @@ -84,13 +84,9 @@ def post_answer(slug, answer): puzzle = Puzzle.objects.get(slug=slug) answer = answer.upper() - local_message = "\N{PARTY POPPER} Confirmed answer: {}".format(answer) - global_message = '\N{PARTY POPPER} Puzzle "{name}" (#{slug}) was solved! The answer is: {answer}'.format( - answer=answer, - slug=slug, - name=puzzle.name - ) - post_local_and_global(slug, local_message, global_message) + local_message = f"{TRIUMPH_EMOJI} Confirmed answer: {answer}\nReact with {LEAVE_EMOJI} to leave the puzzle!" + global_message = f'{TRIUMPH_EMOJI} Puzzle "{puzzle.name}" (#{slug}) from round {puzzle.parent.name} was solved! The answer is: {answer}\nReact with {LEAVE_EMOJI} to leave the puzzle!' + post_local_and_global(slug, local_message, global_message, LEAVE_EMOJI, LEAVE_EMOJI) @optional_task @@ -102,13 +98,8 @@ def post_update(slug, updated_field, value): puzzle = Puzzle.objects.get(slug=slug) except Puzzle.DoesNotExist: return - local_message = '{} set to: {}'.format(updated_field, value) - global_message = '"{name}" (#{slug}) now has these {field}: {value}'.format( - field=updated_field, - value=value, - slug=slug, - name=puzzle.name - ) + local_message = f'{updated_field} set to: {value}' + global_message = f'"{puzzle.name}" (#{slug}) from round {puzzle.parent.name} now has these {updated_field}: {value}' post_local_and_global(slug, local_message, global_message)