async def send_rules_message( msg: SlashMessage, rules: List[Rule], subreddit: str, start_time: datetime, localization_key: str, ) -> None: """Send an embed containing the rules to the user.""" embed = Embed( title=i18n[localization_key]["embed_title"].format(subreddit)) for rule in rules: # The value field is not allowed to be a blank string # So we just repeat the name of the rule if it is not provided embed.add_field( name=rule.short_name, value=rule.description or rule.short_name, inline=False, ) await msg.edit( content=i18n["rules"]["embed_message"].format( get_duration_str(start_time)), embed=embed, )
async def _heatmap( self, ctx: SlashContext, username: Optional[str] = "me", after: Optional[str] = None, before: Optional[str] = None, ) -> None: """Generate a heatmap for the given user.""" start = datetime.now() after_time, before_time, time_str = parse_time_constraints( after, before) msg = await ctx.send(i18n["heatmap"]["getting_heatmap"].format( user=get_initial_username(username, ctx), time_str=time_str)) utc_offset = extract_utc_offset(ctx.author.display_name) from_str = after_time.isoformat() if after_time else None until_str = before_time.isoformat() if before_time else None user = get_user(username, ctx, self.blossom_api) heatmap_response = self.blossom_api.get( "submission/heatmap/", params={ "completed_by": get_user_id(user), "utc_offset": utc_offset, "complete_time__gte": from_str, "complete_time__lte": until_str, }, ) if heatmap_response.status_code != 200: raise BlossomException(heatmap_response) data = heatmap_response.json() day_index = pd.Index(range(1, 8)) hour_index = pd.Index(range(0, 24)) heatmap = ( # Create a data frame from the data pd.DataFrame.from_records(data, columns=["day", "hour", "count"]) # Convert it into a table with the days as rows and hours as columns .pivot(index="day", columns="hour", values="count") # Add the missing days and hours .reindex(index=day_index, columns=hour_index)) heatmap_table = create_file_from_heatmap(heatmap, user, utc_offset) await msg.edit( content=i18n["heatmap"]["response_message"].format( user=get_username(user), time_str=time_str, duration=get_duration_str(start), ), file=heatmap_table, )
async def _find(self, ctx: SlashContext, reddit_url: str) -> None: """Find the post with the given URL.""" start = datetime.now() # Send a first message to show that the bot is responsive. # We will edit this message later with the actual content. msg = await ctx.send(i18n["find"]["looking_for_posts"].format(url=reddit_url)) find_response = self.blossom_api.get("find", params={"url": reddit_url}) if not find_response.ok: await msg.edit(content=i18n["find"]["not_found"].format(url=reddit_url)) return data = find_response.json() await msg.edit( content=i18n["find"]["embed_message"].format( duration=get_duration_str(start) ), embed=to_embed(data), )
async def leaderboard( self, ctx: SlashContext, username: str = "me", after: Optional[str] = None, before: Optional[str] = None, ) -> None: """Get the leaderboard for the given user.""" start = datetime.now(tz=pytz.utc) after_time, before_time, time_str = parse_time_constraints(after, before) # Send a first message to show that the bot is responsive. # We will edit this message later with the actual content. msg = await ctx.send( i18n["leaderboard"]["getting_leaderboard"].format( user=get_initial_username(username, ctx), time_str=time_str ) ) user = get_user(username, ctx, self.blossom_api) top_count = 5 if user else 15 context_count = 5 from_str = after_time.isoformat() if after_time else None until_str = before_time.isoformat() if before_time else None # Get the leaderboard data leaderboard_response = self.blossom_api.get( "submission/leaderboard", params={ "user_id": get_user_id(user), "top_count": top_count, "below_count": context_count, "above_count": context_count, "complete_time__gte": from_str, "complete_time__lte": until_str, }, ) if leaderboard_response.status_code != 200: raise BlossomException(leaderboard_response) leaderboard = leaderboard_response.json() # Extract needed data top_users = leaderboard["top"] above_users = leaderboard["above"] lb_user = leaderboard["user"] below_users = leaderboard["below"] description = "" # Only show the top users if they are not already included top_user_limit = ( top_count + 1 if user is None else above_users[0]["rank"] if len(above_users) > 0 else lb_user["rank"] ) # Show top users for top_user in top_users[: top_user_limit - 1]: description += format_leaderboard_user(top_user) + "\n" rank = get_rank(top_users[0]["gamma"]) if user: # Add separator if necessary if top_user_limit > top_count + 1: description += "...\n" # Show users with more gamma than the current user for above_user in above_users: description += format_leaderboard_user(above_user) + "\n" # Show the current user description += "**" + format_leaderboard_user(lb_user) + "**\n" # Show users with less gamma than the current user for below_user in below_users: description += format_leaderboard_user(below_user) + "\n" rank = get_rank(user["gamma"]) time_frame = format_leaderboard_timeframe(after_time, before_time) await msg.edit( content=i18n["leaderboard"]["embed_message"].format( user=get_username(user), time_str=time_str, duration=get_duration_str(start), ), embed=Embed( title=i18n["leaderboard"]["embed_title"].format( user=get_username(user), time_frame=time_frame ), description=description, color=Colour.from_rgb(*get_rgb_from_hex(rank["color"])), ), )
async def _search_from_cache( self, msg: SlashMessage, start: datetime, cache_item: SearchCacheItem, page_mod: int, ) -> None: """Execute the search with the given cache.""" # Clear previous control emojis await clear_reactions(msg) discord_page = cache_item["cur_page"] + page_mod query = cache_item["query"] user = cache_item["user"] user_id = user["id"] if user else None after_time = cache_item["after_time"] before_time = cache_item["before_time"] time_str = cache_item["time_str"] from_str = after_time.isoformat() if after_time else None until_str = before_time.isoformat() if before_time else None request_page = (discord_page * self.discord_page_size) // self.request_page_size if (not cache_item["response_data"] or request_page != cache_item["request_page"]): # A new request has to be made data = { "text__icontains": cache_item["query"], "author": user_id, "create_time__gte": from_str, "create_time__lte": until_str, "url__isnull": False, "ordering": "-create_time", "page_size": self.request_page_size, "page": request_page + 1, } response = self.blossom_api.get(path="transcription", params=data) if response.status_code != 200: raise BlossomException(response) response_data = response.json() else: response_data = cache_item["response_data"] if response_data["count"] == 0: await msg.edit(content=i18n["search"]["no_results"].format( query=query, user=get_username(user), time_str=time_str, duration_str=get_duration_str(start), )) return # Only cache the result if the user can change pages if response_data["count"] > self.discord_page_size: # Update the cache self.cache.set( msg.id, { "query": query, "user": cache_item["user"], "after_time": after_time, "before_time": before_time, "time_str": time_str, "cur_page": discord_page, "discord_user_id": cache_item["discord_user_id"], "response_data": response_data, "request_page": request_page, }, ) # Calculate the offset within the response # The requested pages are larger than the pages displayed on Discord request_offset = request_page * self.request_page_size discord_offset = discord_page * self.discord_page_size result_offset = discord_offset - request_offset page_results: List[Dict[ str, Any]] = response_data["results"][result_offset:result_offset + self.discord_page_size] description = "" for i, res in enumerate(page_results): description += create_result_description(res, discord_offset + i + 1, query) total_discord_pages = math.ceil(response_data["count"] / self.discord_page_size) await msg.edit( content=i18n["search"]["embed_message"].format( query=query, user=get_username(user), time_str=time_str, duration_str=get_duration_str(start), ), embed=Embed( title=i18n["search"]["embed_title"].format( query=query, user=get_username(user)), description=description, ).set_footer(text=i18n["search"]["embed_footer"].format( cur_page=discord_page + 1, total_pages=total_discord_pages, total_results=response_data["count"], ), ), ) emoji_controls = [] # Determine which controls are appropriate if discord_page > 0: emoji_controls.append(first_page_emoji) emoji_controls.append(previous_page_emoji) if discord_page < total_discord_pages - 1: emoji_controls.append(next_page_emoji) emoji_controls.append(last_page_emoji) # Add control emojis to message await asyncio.gather( *[msg.add_reaction(emoji) for emoji in emoji_controls])
async def _until( self, ctx: SlashContext, goal: Optional[str] = None, username: str = "me", after: str = "1 week", before: Optional[str] = None, ) -> None: """Determine how long it will take the user to reach the given goal.""" start = datetime.now(tz=pytz.utc) after_time, before_time, time_str = parse_time_constraints( after, before) if not after_time: # We need a starting point for the calculations raise InvalidArgumentException("after", after) # Send a first message to show that the bot is responsive. # We will edit this message later with the actual content. msg = await ctx.send(i18n["until"]["getting_prediction"].format( user=get_initial_username(username, ctx), time_str=time_str, )) user = get_user(username, ctx, self.blossom_api) if goal is not None: try: # Check if the goal is a gamma value or rank name goal_gamma, goal_str = parse_goal_str(goal) except InvalidArgumentException: # The goal could be a username if not user: # If the user is the combined server, a target user doesn't make sense raise InvalidArgumentException("goal", goal) # Try to treat the goal as a user return await self._until_user_catch_up( ctx, msg, user, goal, start, after_time, before_time, time_str, ) elif user: # Take the next rank for the user next_rank = get_next_rank(user["gamma"]) if next_rank: goal_gamma, goal_str = parse_goal_str(next_rank["name"]) else: # If the user has reached the maximum rank, take the next 10,000 tier goal_gamma = ((user["gamma"] + 10_000) // 10_000) * 10_000 goal_str = f"{goal_gamma:,}" else: # You can't get the "next rank" of the whole server raise InvalidArgumentException("goal", "<empty>") user_gamma = get_user_gamma(user, self.blossom_api) await msg.edit( content=i18n["until"]["getting_prediction_to_goal"].format( user=get_username(user), goal=goal_str, time_str=time_str, )) description = await _get_progress_description( user, user_gamma, goal_gamma, goal_str, start, after_time, before_time, blossom_api=self.blossom_api, ) # Determine the color of the target rank color = get_rank(goal_gamma)["color"] await msg.edit( content=i18n["until"]["embed_message"].format( user=get_username(user), goal=goal_str, time_str=time_str, duration=get_duration_str(start), ), embed=Embed( title=i18n["until"]["embed_title"].format( user=get_username(user)), description=description, color=discord.Colour.from_rgb(*get_rgb_from_hex(color)), ), )
async def _until_user_catch_up( self, ctx: SlashContext, msg: SlashMessage, user: BlossomUser, target_username: str, start: datetime, after_time: datetime, before_time: Optional[datetime], time_str: str, ) -> None: """Determine how long it will take the user to catch up with the target user.""" # Try to find the target user try: target = get_user(target_username, ctx, self.blossom_api) except UserNotFound: # This doesn't mean the username is wrong # They could have also mistyped a rank # So we change the error message to something else raise InvalidArgumentException("goal", target_username) if not target: # Having the combined server as target doesn't make sense # Because it includes the current user, they could never reach it raise InvalidArgumentException("goal", target_username) if user["gamma"] > target["gamma"]: # Swap user and target, the target has to have more gamma # Otherwise the goal would have already been reached user, target = target, user user_progress = await _get_user_progress(user, after_time, before_time, blossom_api=self.blossom_api) target_progress = await _get_user_progress( target, after_time, before_time, blossom_api=self.blossom_api) time_frame = (before_time or start) - after_time if user_progress <= target_progress: description = i18n["until"]["embed_description_user_never"].format( user=get_username(user), user_gamma=user["gamma"], user_progress=user_progress, target=get_username(target), target_gamma=target["gamma"], target_progress=target_progress, time_frame=get_timedelta_str(time_frame), ) else: # Calculate time needed seconds_needed = (target["gamma"] - user["gamma"]) / ( (user_progress - target_progress) / time_frame.total_seconds()) relative_time = timedelta(seconds=seconds_needed) absolute_time = start + relative_time intersection_gamma = user["gamma"] + math.ceil( (user_progress / time_frame.total_seconds()) * relative_time.total_seconds()) description = i18n["until"][ "embed_description_user_prediction"].format( user=get_username(user), user_gamma=user["gamma"], user_progress=user_progress, target=get_username(target), target_gamma=target["gamma"], target_progress=target_progress, intersection_gamma=intersection_gamma, time_frame=get_timedelta_str(time_frame), relative_time=get_timedelta_str(relative_time), absolute_time=get_discord_time_str(absolute_time), ) color = get_rank(target["gamma"])["color"] await msg.edit( content=i18n["until"]["embed_message"].format( user=get_username(user), goal=get_username(target), time_str=time_str, duration=get_duration_str(start), ), embed=Embed( title=i18n["until"]["embed_title"].format( user=get_username(user)), description=description, color=discord.Colour.from_rgb(*get_rgb_from_hex(color)), ), )
async def rate( self, ctx: SlashContext, usernames: str = "me", after: Optional[str] = None, before: Optional[str] = None, ) -> None: """Get the transcription rate of the user.""" start = datetime.now() after_time, before_time, time_str = parse_time_constraints( after, before) utc_offset = extract_utc_offset(ctx.author.display_name) # Give a quick response to let the user know we're working on it # We'll later edit this message with the actual content msg = await ctx.send(i18n["rate"]["getting_rate"].format( users=get_initial_username_list(usernames, ctx), time_str=time_str, )) users = get_user_list(usernames, ctx, self.blossom_api) if users: users.sort(key=lambda u: u["gamma"], reverse=True) colors = get_user_colors(users) max_rates = [] fig: plt.Figure = plt.figure() ax: plt.Axes = fig.gca() fig.subplots_adjust(bottom=0.2) ax.set_xlabel(i18n["rate"]["plot_xlabel"].format( timezone=utc_offset_to_str(utc_offset))) ax.set_ylabel(i18n["rate"]["plot_ylabel"]) for label in ax.get_xticklabels(): label.set_rotation(32) label.set_ha("right") ax.set_title(i18n["rate"]["plot_title"].format( users=get_usernames(users, 2, escape=False))) for index, user in enumerate(users or [None]): if users and len(users) > 1: await msg.edit(content=i18n["rate"]["getting_rate"].format( users=get_usernames(users), count=index + 1, total=len(users), time_str=time_str, )) user_data = self.get_all_rate_data(user, "day", after_time, before_time, utc_offset) max_rate = user_data["count"].max() max_rates.append(max_rate) max_rate_point = user_data[user_data["count"] == max_rate].iloc[0] color = colors[index] # Plot the graph ax.plot( "date", "count", data=user_data.reset_index(), color=color, ) # At a point for the max value ax.scatter( max_rate_point.name, max_rate_point.at["count"], color=color, s=4, ) # Label the max value ax.annotate( int(max_rate_point.at["count"]), xy=(max_rate_point.name, max_rate_point.at["count"]), color=color, ) if users: # A milestone at every 100 rate milestones = [ dict(threshold=i * 100, color=ranks[i + 2]["color"]) for i in range(1, 8) ] ax = add_milestone_lines(ax, milestones, 0, max(max_rates), 40) if users and len(users) > 1: ax.legend([get_username(user, escape=False) for user in users]) discord_file = create_file_from_figure(fig, "rate_plot.png") await msg.edit( content=i18n["rate"]["response_message"].format( usernames=get_usernames(users), time_str=time_str, duration=get_duration_str(start), ), file=discord_file, )
async def history( self, ctx: SlashContext, usernames: str = "me", after: Optional[str] = None, before: Optional[str] = None, ) -> None: """Get the transcription history of the user.""" start = datetime.now() after_time, before_time, time_str = parse_time_constraints( after, before) utc_offset = extract_utc_offset(ctx.author.display_name) # Give a quick response to let the user know we're working on it # We'll later edit this message with the actual content msg = await ctx.send(i18n["history"]["getting_history"].format( users=get_initial_username_list(usernames, ctx), time_str=time_str, )) users = get_user_list(usernames, ctx, self.blossom_api) if users: users.sort(key=lambda u: u["gamma"], reverse=True) colors = get_user_colors(users) min_gammas = [] max_gammas = [] fig: plt.Figure = plt.figure() ax: plt.Axes = fig.gca() fig.subplots_adjust(bottom=0.2) ax.set_xlabel(i18n["history"]["plot_xlabel"].format( timezone=utc_offset_to_str(utc_offset))) ax.set_ylabel(i18n["history"]["plot_ylabel"]) for label in ax.get_xticklabels(): label.set_rotation(32) label.set_ha("right") ax.set_title(i18n["history"]["plot_title"].format( users=get_usernames(users, 2, escape=False))) for index, user in enumerate(users or [None]): if users and len(users) > 1: await msg.edit( content=i18n["history"]["getting_history_progress"].format( users=get_usernames(users), time_str=time_str, count=index + 1, total=len(users), )) history_data = self.get_user_history(user, after_time, before_time, utc_offset) color = colors[index] first_point = history_data.iloc[0] last_point = history_data.iloc[-1] min_gammas.append(first_point.at["gamma"]) max_gammas.append(last_point.at["gamma"]) # Plot the graph ax.plot( "date", "gamma", data=history_data.reset_index(), color=color, ) # At a point for the last value ax.scatter( last_point.name, last_point.at["gamma"], color=color, s=4, ) # Label the last value ax.annotate( int(last_point.at["gamma"]), xy=(last_point.name, last_point.at["gamma"]), color=color, ) if users: # Show milestone lines min_value, max_value = min(min_gammas), max(max_gammas) delta = (max_value - min_value) * 0.4 ax = add_milestone_lines(ax, ranks, min_value, max_value, delta) if users and len(users) > 1: ax.legend([get_username(user, escape=False) for user in users]) discord_file = create_file_from_figure(fig, "history_plot.png") await msg.edit( content=i18n["history"]["response_message"].format( users=get_usernames(users), time_str=time_str, duration=get_duration_str(start), ), file=discord_file, )
async def _partner(self, ctx: SlashContext, subreddit: Optional[str] = None) -> None: """Get the list of all our partner subreddits.""" start = datetime.now() if subreddit is None: msg = await ctx.send(i18n["partner"]["getting_partner_list"]) else: msg = await ctx.send( i18n["partner"]["getting_partner_status"].format( subreddit=subreddit)) partners = await self._get_partner_list() if subreddit is None: partner_str = join_items_with_and(partners) await msg.edit( content=i18n["partner"]["embed_partner_list_message"].format( duration=get_duration_str(start)), embed=Embed( title=i18n["partner"]["embed_partner_list_title"], description=i18n["partner"] ["embed_partner_list_description"].format( count=len(partners), partner_list=partner_str), ), ) else: sub = await self.reddit_api.subreddit(subreddit) is_private = False try: await sub.load() except Redirect: # The subreddit does not exist await msg.edit(content=i18n["partner"]["sub_not_found"].format( subreddit=subreddit)) return except NotFound: # A character in the sub name is not allowed await msg.edit(content=i18n["partner"]["sub_not_found"].format( subreddit=subreddit)) return except Forbidden: # The subreddit is private is_private = True is_partner = subreddit.casefold() in [ partner.casefold() for partner in partners ] message = i18n["partner"]["embed_partner_status_message"].format( subreddit=subreddit, duration=get_duration_str(start)) status_message = (i18n["partner"]["status_yes_message"].format( subreddit=subreddit) if is_partner else i18n["partner"]["status_no_message"].format( subreddit=subreddit)) if is_private: status_message += i18n["partner"]["private_message"] else: status_message += "\n" + i18n["partner"][ "sub_description"].format( description=sub.public_description) color = (Color.red() if not is_partner else Color.orange() if is_private else Color.green()) await msg.edit( content=message, embed=Embed( title=i18n["partner"]["embed_partner_status_title"].format( subreddit=subreddit), description=i18n["partner"] ["embed_partner_status_description"].format( status=status_message), color=color, ), )