From 8125b02e5eeeb369ca4c14fd87f6892e76cf12b8 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Tue, 9 Jan 2024 05:42:27 -0500 Subject: [PATCH] first cut at keyword subscriptions --- herring/herring/settings.py | 1 + herring/puzzles/discordbot.py | 42 +++++++++++++++++++ herring/puzzles/forms.py | 2 +- .../migrations/0016_auto_20240109_0509.py | 23 ++++++++++ .../0017_userprofile_subscriptions.py | 22 ++++++++++ herring/puzzles/models.py | 3 +- herring/puzzles/signals.py | 14 ++++++- herring/puzzles/static/style.less | 6 +++ herring/puzzles/tasks.py | 35 ++++++++++++++++ 9 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 herring/puzzles/migrations/0016_auto_20240109_0509.py create mode 100644 herring/puzzles/migrations/0017_userprofile_subscriptions.py diff --git a/herring/herring/settings.py b/herring/herring/settings.py index e5f0817..963d6ab 100644 --- a/herring/herring/settings.py +++ b/herring/herring/settings.py @@ -39,6 +39,7 @@ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', + 'django.contrib.postgres', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', diff --git a/herring/puzzles/discordbot.py b/herring/puzzles/discordbot.py index c400ea7..b0eec25 100644 --- a/herring/puzzles/discordbot.py +++ b/herring/puzzles/discordbot.py @@ -230,6 +230,30 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): target_channel = message.channel_mentions[0] await self.add_user_to_puzzle(payload.member, target_channel.name) + # TODO: Merge this with the above when not in ultra-conservative mode + try: + if payload.emoji.name == SIGNUP_EMOJI: + member = self.guild.get_member(payload.user_id) + if member is not None: + dm_channel = member.dm_channel + if dm_channel is None: + # This seems to be the case if the bot is restarted and + # an old message is reacted to. + dm_channel = await member.create_dm() + if dm_channel.id == payload.channel_id: + message: discord.Message = await dm_channel.fetch_message(payload.message_id) + if message.author.id == self.bot.user.id: + # Can't remove the user's reactions from a DM, apparently... + + # message.channel_mentions is always an empty list in + # DMs, per the documentation, so we must do what we + # must do... + match = re.search('\(#([^)]*)\)', message.content) + if match is not None: + await self.add_user_to_puzzle(member, match[1]) + except Exception as e: + logging.error(f"on_raw_reaction_add: error in subscription reaction handler", e) + @commands.Cog.listener() async def on_message(self, message: discord.Message): logging.info("on_message: %s", message) @@ -1186,6 +1210,24 @@ def get_channel_pair(self, puzzle_name): voice_channel: discord.VoiceChannel = get(self.guild.voice_channels, name=puzzle_name) return text_channel, voice_channel + async def send_subscription_messages(self, puzzle_name: str, discord_ids: list[str], message_text: str): + 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 send_subscription_messages!") + return + message_text += f" {SIGNUP_EMOJI} this message to join, then click here to jump to the channel: {channel.mention}." + for discord_identifier in discord_ids: + member = self.guild.get_member_named(discord_identifier) + if member is None: + logging.warning(f"couldn't find member named {discord_identifier}") + continue + if member in channel.overwrites: + continue + message = await member.send(message_text) + await message.add_reaction(SIGNUP_EMOJI) + + @lazy_object def DISCORD_ANNOUNCER() -> Optional[HerringAnnouncerBot]: if not settings.HERRING_ACTIVATE_DISCORD: diff --git a/herring/puzzles/forms.py b/herring/puzzles/forms.py index b78304d..85cc424 100644 --- a/herring/puzzles/forms.py +++ b/herring/puzzles/forms.py @@ -26,5 +26,5 @@ class Meta: class UserProfileForm(forms.ModelForm): class Meta: model = UserProfile - fields = ('discord_identifier',) + fields = ('discord_identifier', 'subscriptions') # Exclude avatar_url because, to my knowledge, it's literally never used. diff --git a/herring/puzzles/migrations/0016_auto_20240109_0509.py b/herring/puzzles/migrations/0016_auto_20240109_0509.py new file mode 100644 index 0000000..00c2698 --- /dev/null +++ b/herring/puzzles/migrations/0016_auto_20240109_0509.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2024-01-09 05:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzles', '0015_channelparticipation_display_name'), + ] + + operations = [ + migrations.AlterField( + model_name='puzzle', + name='hunt_id', + field=models.IntegerField(default=2024), + ), + migrations.AlterField( + model_name='round', + name='hunt_id', + field=models.IntegerField(default=2024), + ), + ] diff --git a/herring/puzzles/migrations/0017_userprofile_subscriptions.py b/herring/puzzles/migrations/0017_userprofile_subscriptions.py new file mode 100644 index 0000000..6f20ccd --- /dev/null +++ b/herring/puzzles/migrations/0017_userprofile_subscriptions.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.16 on 2024-01-09 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzles', '0016_auto_20240109_0509'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='subscriptions', + field=models.TextField(blank=True, null=True), + ), + migrations.RunSQL( + sql="""CREATE INDEX puzzles_userprofile_subscriptions_search ON "puzzles_userprofile" USING GIN (to_tsvector('english', COALESCE("puzzles_userprofile"."subscriptions", '')));""", + reverse_sql="""DROP INDEX puzzles_userprofile_subscriptions_search;""", + ), + ] diff --git a/herring/puzzles/models.py b/herring/puzzles/models.py index ad2fa2d..11d50b0 100644 --- a/herring/puzzles/models.py +++ b/herring/puzzles/models.py @@ -170,9 +170,10 @@ class UserProfile(models.Model, JSONMixin): ) avatar_url = models.CharField(max_length=200, **optional) discord_identifier = models.CharField(max_length=200, **optional) + subscriptions = models.TextField(**optional) class Json: - include_fields = ['avatar_url', 'discord_identifier'] + include_fields = ['avatar_url', 'discord_identifier', 'subscriptions'] def __str__(self): return "profile for " + self.user.__str__() diff --git a/herring/puzzles/signals.py b/herring/puzzles/signals.py index 1ca7bd0..92c2e17 100644 --- a/herring/puzzles/signals.py +++ b/herring/puzzles/signals.py @@ -1,8 +1,9 @@ +import re from django.contrib.auth.models import User from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.db import transaction -from puzzles.tasks import create_puzzle_sheet_and_channel, post_answer, post_update, create_round_category +from puzzles.tasks import create_puzzle_sheet_and_channel, post_answer, post_update, create_round_category, notify_subscribers from puzzles.models import Puzzle, Round, UserProfile @receiver(post_save, sender=Puzzle) @@ -17,11 +18,22 @@ def before_puzzle_save(sender, instance, **kwargs): if instance.answer != instance.tracker.previous('answer'): post_answer.delay(instance.slug, instance.answer) + old_keywords = set() + new_keywords = set() + if instance.tags != instance.tracker.previous('tags') and instance.tracker.previous('tags') is not None: post_update.delay(instance.slug, 'tags', instance.tags) + old_keywords.update(re.findall('\w+', instance.tracker.previous('tags').lower())) + new_keywords.update(re.findall('\w+', instance.tags.lower())) if instance.note != instance.tracker.previous('note') and instance.tracker.previous('note') is not None: post_update.delay(instance.slug, 'notes', instance.note) + old_keywords.update(re.findall('\w+', instance.tracker.previous('note').lower())) + new_keywords.update(re.findall('\w+', instance.note.lower())) + + new_keywords.difference_update(old_keywords) + if len(new_keywords) > 0 and instance.answer == '': + notify_subscribers.delay(instance.slug, '|'.join(new_keywords)) @receiver(post_save, sender=Round) diff --git a/herring/puzzles/static/style.less b/herring/puzzles/static/style.less index 8481975..773af5b 100644 --- a/herring/puzzles/static/style.less +++ b/herring/puzzles/static/style.less @@ -440,3 +440,9 @@ button, a.button, input[type=submit] { div.editable > span { pointer-events: none; } + +// Possibly this should apply to all labels, but trying to be +// ultra-conservative right now +label[for="id_subscriptions"] { + vertical-align: top; +} diff --git a/herring/puzzles/tasks.py b/herring/puzzles/tasks.py index 2f54198..cf8f72d 100644 --- a/herring/puzzles/tasks.py +++ b/herring/puzzles/tasks.py @@ -7,6 +7,7 @@ from celery import shared_task from datetime import datetime, timezone from django.conf import settings +from django.contrib.postgres.search import SearchQuery, SearchVector from django.db import transaction import json import kombu.exceptions @@ -112,6 +113,40 @@ def post_update(slug, updated_field, value): post_local_and_global(slug, local_message, global_message) +@optional_task +@shared_task(rate_limit=0.5) +def notify_subscribers(slug: str, keywords: str): + logging.warning("tasks: notify_subscribers(%s, %r)", slug, keywords) + + try: + puzzle = Puzzle.objects.get(slug=slug) + except Puzzle.DoesNotExist: + return + + try: + users = UserProfile.objects.annotate(search=SearchVector('subscriptions', config='english')).filter( + search=SearchQuery(keywords, search_type='raw'), + discord_identifier__isnull=False, + ) + users = list(users) + except Exception as e: + logging.error("tasks: notify_subscribers failed while searching subscriptions", e) + return + + if len(users) == 0: + return + + discord_ids = [u.discord_identifier for u in users] + + message = f"Based on your subscriptions, you might want to join \"{puzzle.name}\" (#{slug})." + if puzzle.tags: + message += f" It has tags \"{puzzle.tags}\"." + if puzzle.note: + message += f" It has notes \"{puzzle.note}\"." + if settings.HERRING_ACTIVATE_DISCORD: + do_in_discord(DISCORD_ANNOUNCER.send_subscription_messages(slug, discord_ids, message)) + + @optional_task @shared_task(bind=True, max_retries=10, default_retry_delay=5, rate_limit=0.25) # rate_limit is in tasks/sec def create_puzzle_sheet_and_channel(self, slug):