Repository: python-discord/bot

Issue:
minor !nominate improvements
* When a user is nominated multiple times, it would be great if this showed in the #talent-pool embed. Something like "Nominated by: Scragly, lemon, fiskenslakt" would make it clear that the user had been nominated multiple times, which should count for something. The reason should not change, but a note should be added.

* It should not be possible to nominate someone who is already a Helper, Mod, Admin or Owner.

* The notes that are added currently are prefixed with `bb watch`. Perhaps for nominations, these could be prefixed with `Helper nomination:` or something.


Discussion:
> - When a user is nominated multiple times, it would be great if this showed in the #talent-pool embed. Something like "Nominated by: Scragly, lemon, fiskenslakt" would make it clear that the user had been nominated multiple times, which should count for something. The reason should not change, but a note should be added.

Okay, I would like some input on this. since there are some advantages and disadvantages to all solutions I've thought of. Not all of them implement all of the ideas expressed here.

First of all, I'm going to assume that for the moment, we'd like to not to change the underlying API. We may want to think about changing it in the future, after the Django move is completed, but if we do, my recommendation would be to consider separating the infraction/user record API from the watch API. (Don't know if that's desirable, but instead of incrementally 'hacking' additional stuff on top of the old one, we may want to do a proper evaluation of it.) So, all of the assumptions below are with the current API in mind.

1. The easiest option, by far, is silently ignoring additional nominations while a user has a current nomination. It's a simple check, but we'll lose information, as the additional/secondary nominations are not stored. I'd say that's not what we want.

2. Storing the additional nominations as notes as if they were the first nomination, but selecting *oldest* instead of the newest nomination as the primary one to display the correct timing and reason. The advantage is that this implementation is straightforward and we store everything we want to store, but is also has one **major** drawback: It introduces a new bug, namely that if someone has more than one separate nomination periods*, the embed will always display the information of the earliest period.

*) A plausible reason this may happen is that someone is nominated, but disappears from the server for a while, triggering us to remove them from consideration. After a while they return and show consistent activity, so we decide to nominate them again to observe them for a while.

3. Storing additional nominations as described in point 2, but altering the prefix compared to the initial/primary nomination. For instance, if someone does not have an active nomination, use the prefix `Helper nomination:` in the note; if someone does have an active nomination use something like `Additional nomination` or `Helper nomination (additional)"` as a prefix in the note. That way, we can always locate the latest "primary" nomination event and we can then work-out with additional nominations belong to it, as they come after it, date-wise.

A downside of doing that is that we're basically storing even more information in a single field: that it's a nomination; whether it's the initial or an additional nomination; and the actual reason itself. That's quite a hacky way of storing information in the database and we should probably split it up into separate fields (That way we can also create a view that allows us to directly query the relevant information instead of using queries with regex searches in fields).

Still, with the last option, we will be able to do what we want. (Store all the information and display it correctly.)
Is changing the API such a bad thing? Django isn't in prod yet (right?) and the old API will be replaced by that at some point anyway.

Just gotta make sure the PRs are merged together.
Django is getting closer to prod every day, and any work put into the flask API adds additional workload to the django branch. It's not ideal to do that at this point.

I think **3** seems like a fairly decent solution. `Additional nomination` seems to make the infraction history more readable anyway.
One other minor thing, but I'll edit the current reasons of the nominated helpers manually to change the prefix. I don't think it's worth it to have fallback code to support backwards compatibility for the old prefix `bb watch` for the very few nominees we currently have.
Option 3 seems fine

I'm going to give a huge +1 to separating out nominations from infractions in the Django API. While this is functional in the short term, continuing to struggle against an API endpoint that wasn't designed for this is generating a lot of hacky solutions and cruft in the infractions DB.
yeah @sco1, I agree. we should see about that in the near future.

Resolving patch:
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -74,20 +74,18 @@ async def bigbrother_watch_alias(
             self, ctx: Context, user: User, *, reason: str
     ):
         """
-        Alias for invoking <prefix>bigbrother watch user [text_channel].
+        Alias for invoking <prefix>bigbrother watch [user] [reason].
         """
 
         await self.invoke(ctx, "bigbrother watch", user, reason=reason)
 
     @command(name="unwatch", hidden=True)
-    async def bigbrother_unwatch_alias(self, ctx, user: User):
+    async def bigbrother_unwatch_alias(self, ctx, user: User, *, reason: str):
         """
-        Alias for invoking <prefix>bigbrother unwatch user.
-
-        user: discord.User - A user instance to unwatch
+        Alias for invoking <prefix>bigbrother unwatch [user] [reason].
         """
 
-        await self.invoke(ctx, "bigbrother unwatch", user)
+        await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
 
     @command(name="home", hidden=True)
     async def site_home_alias(self, ctx):
@@ -108,7 +106,7 @@ async def site_faq_alias(self, ctx):
     @command(name="reload", hidden=True)
     async def cogs_reload_alias(self, ctx, *, cog_name: str):
         """
-        Alias for invoking <prefix>cogs reload cog_name.
+        Alias for invoking <prefix>cogs reload [cog_name].
 
         cog_name: str - name of the cog to be reloaded.
         """
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
--- a/bot/cogs/bigbrother.py
+++ b/bot/cogs/bigbrother.py
@@ -44,6 +44,7 @@ def __init__(self, bot: Bot):
         self.last_log = [None, None, 0]  # [user_id, channel_id, message_count]
         self.consuming = False
         self.infraction_watch_prefix = "bb watch: "  # Please do not change or we won't be able to find old reasons
+        self.nomination_prefix = "Helper nomination: "
 
         self.bot.loop.create_task(self.get_watched_users())
 
@@ -77,10 +78,20 @@ async def get_watched_users(self):
             data = await response.json()
             self.update_cache(data)
 
-    async def get_watch_information(self, user_id: int) -> WatchInformation:
+    async def update_watched_users(self):
+        async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
+            if response.status == 200:
+                data = await response.json()
+                self.update_cache(data)
+                log.trace("Updated Big Brother watchlist cache")
+                return True
+            else:
+                return False
+
+    async def get_watch_information(self, user_id: int, prefix: str) -> WatchInformation:
         """ Fetches and returns the latest watch reason for a user using the infraction API """
 
-        re_bb_watch = rf"^{self.infraction_watch_prefix}"
+        re_bb_watch = rf"^{prefix}"
         user_id = str(user_id)
 
         try:
@@ -108,7 +119,7 @@ async def get_watch_information(self, user_id: int) -> WatchInformation:
             date = latest_reason_infraction["inserted_at"]
 
             # Get the latest reason without the prefix
-            latest_reason = latest_reason_infraction['reason'][len(self.infraction_watch_prefix):]
+            latest_reason = latest_reason_infraction['reason'][len(prefix):]
 
             log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}")
             return WatchInformation(reason=latest_reason, actor_id=actor_id, inserted_at=date)
@@ -208,7 +219,11 @@ async def send_header(self, message: Message, destination: TextChannel):
             # Retrieve watch reason from API if it's not already in the cache
             if message.author.id not in self.watch_reasons:
                 log.trace(f"No watch information for {message.author.id} found in cache; retrieving from API")
-                user_watch_information = await self.get_watch_information(message.author.id)
+                if destination == self.bot.get_channel(Channels.talent_pool):
+                    prefix = self.nomination_prefix
+                else:
+                    prefix = self.infraction_watch_prefix
+                user_watch_information = await self.get_watch_information(message.author.id, prefix)
                 self.watch_reasons[message.author.id] = user_watch_information
 
             self.last_log = [message.author.id, message.channel.id, 0]
@@ -235,6 +250,13 @@ async def send_header(self, message: Message, destination: TextChannel):
 
                     # Adding nomination info to author_field
                     author_field = f"{author_field} (nominated {time_delta} by {actor})"
+            else:
+                if inserted_at:
+                    # Get time delta since insertion
+                    date_time = parse_rfc1123(inserted_at).replace(tzinfo=None)
+                    time_delta = time_since(date_time, precision="minutes", max_units=1)
+
+                    author_field = f"{author_field} (added {time_delta})"
 
             embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})")
             embed.set_author(name=author_field, icon_url=message.author.avatar_url)
@@ -291,7 +313,6 @@ async def _watch_user(self, ctx: Context, user: User, reason: str, channel_id: i
                     self.watched_users[user.id] = channel
 
                     # Add a note (shadow warning) with the reason for watching
-                    reason = f"{self.infraction_watch_prefix}{reason}"
                     await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
             else:
                 data = await response.json()
@@ -313,35 +334,25 @@ async def watched_command(self, ctx: Context, from_cache: bool = True):
         By default, the users are returned from the cache.
         If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'.
         """
-
-        if from_cache:
-            lines = tuple(
-                f"• <@{user_id}> in <#{self.watched_users[user_id].id}>"
-                for user_id in self.watched_users
-            )
-            await LinePaginator.paginate(
-                lines or ("There's nothing here yet.",),
-                ctx,
-                Embed(title="Watched users (cached)", color=Color.blue()),
-                empty=False
-            )
-
+        if not from_cache:
+            updated = await self.update_watched_users()
+            if not updated:
+                await ctx.send(f":x: Failed to update cache: non-200 response from the API")
+                return
+            title = "Watched users (updated cache)"
         else:
-            async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
-                if response.status == 200:
-                    data = await response.json()
-                    self.update_cache(data)
-                    lines = tuple(f"• <@{entry['user_id']}> in <#{entry['channel_id']}>" for entry in data)
-
-                    await LinePaginator.paginate(
-                        lines or ("There's nothing here yet.",),
-                        ctx,
-                        Embed(title="Watched users", color=Color.blue()),
-                        empty=False
-                    )
-
-                else:
-                    await ctx.send(f":x: got non-200 response from the API")
+            title = "Watched users (from cache)"
+
+        lines = tuple(
+            f"• <@{user_id}> in <#{self.watched_users[user_id].id}>"
+            for user_id in self.watched_users
+        )
+        await LinePaginator.paginate(
+            lines or ("There's nothing here yet.",),
+            ctx,
+            Embed(title=title, color=Color.blue()),
+            empty=False
+        )
 
     @bigbrother_group.command(name='watch', aliases=('w',))
     @with_role(*MODERATION_ROLES)
@@ -353,14 +364,28 @@ async def watch_command(self, ctx: Context, user: User, *, reason: str):
         note (aka: shadow warning)
         """
 
+        # Update cache to avoid double watching of a user
+        await self.update_watched_users()
+
+        if user.id in self.watched_users:
+            message = f":x: User is already being watched in {self.watched_users[user.id].name}"
+            await ctx.send(message)
+            return
+
         channel_id = Channels.big_brother_logs
 
+        reason = f"{self.infraction_watch_prefix}{reason}"
+
         await self._watch_user(ctx, user, reason, channel_id)
 
     @bigbrother_group.command(name='unwatch', aliases=('uw',))
     @with_role(*MODERATION_ROLES)
-    async def unwatch_command(self, ctx: Context, user: User):
-        """Stop relaying messages by the given `user`."""
+    async def unwatch_command(self, ctx: Context, user: User, *, reason: str):
+        """
+        Stop relaying messages by the given `user`.
+
+        A `reason` for unwatching is required, which will be added as a note to the user.
+        """
 
         url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
         async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
@@ -368,14 +393,20 @@ async def unwatch_command(self, ctx: Context, user: User):
                 await ctx.send(f":ok_hand: will no longer relay messages sent by {user}")
 
                 if user.id in self.watched_users:
+                    channel = self.watched_users[user.id]
+
                     del self.watched_users[user.id]
                     if user.id in self.channel_queues:
                         del self.channel_queues[user.id]
                     if user.id in self.watch_reasons:
                         del self.watch_reasons[user.id]
                 else:
+                    channel = None
                     log.warning(f"user {user.id} was unwatched but was not found in the cache")
 
+                reason = f"Unwatched ({channel.name if channel else 'unknown channel'}): {reason}"
+                await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+
             else:
                 data = await response.json()
                 reason = data.get('error_message', "no message provided")
@@ -395,8 +426,33 @@ async def nominate_command(self, ctx: Context, user: User, *, reason: str):
         # !nominate command does not show up under "BigBrother" in the help embed, but under
         # the header HelperNomination for users with the helper role.
 
+        member = ctx.guild.get_member(user.id)
+
+        if member and any(role.id in STAFF_ROLES for role in member.roles):
+            await ctx.send(f":x: {user.mention} is already a staff member!")
+            return
+
         channel_id = Channels.talent_pool
 
+        # Update watch cache to avoid overwriting active nomination reason
+        await self.update_watched_users()
+
+        if user.id in self.watched_users:
+            if self.watched_users[user.id].id == Channels.talent_pool:
+                prefix = "Additional nomination: "
+            else:
+                # If the user is being watched in big-brother, don't add them to talent-pool
+                message = (
+                    f":x: {user.mention} can't be added to the talent-pool "
+                    "as they are currently being watched in big-brother."
+                )
+                await ctx.send(message)
+                return
+        else:
+            prefix = self.nomination_prefix
+
+        reason = f"{prefix}{reason}"
+
         await self._watch_user(ctx, user, reason, channel_id)
 
 


Test patch: