async def emoji(self, context: commands.Context, lottery_id: int, emoji: typing.Union[discord.Emoji, str]): message, channel, previous_emoji, nb_winners, time, organizer = \ await self.get_message_env(lottery_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if isinstance(emoji, str) and emojis.emojis.count(emoji) != 1: raise exceptions.ForbiddenEmoji(emoji) previous_reaction = utils.try_get( message.reactions, error=exceptions.MissingEmoji(previous_emoji), emoji=previous_emoji) await previous_reaction.remove(zbot.bot.user) await message.add_reaction(emoji) embed = self.build_announce_embed(emoji, nb_winners, organizer, time, self.guild.roles) await message.edit(embed=embed) job_id = self.pending_lotteries[message.id]['_id'] lottery_data = { 'emoji_code': emoji if isinstance(emoji, str) else emoji.id } zbot.db.update_job_data(self.JOBSTORE, job_id, lottery_data) self.pending_lotteries[message.id].update(lottery_data) await context.send( f"Émoji du tirage au sort d'identifiant `{lottery_id}` remplacé par \"{emoji}\" : " f"<{message.jump_url}>")
async def announce(self, context: commands.Context, poll_id: int, announce: str, *, options=""): message, channel, _, _, _, _, organizer = await self.get_message_env( poll_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if not context.author.permissions_in(channel).send_messages: raise exceptions.ForbiddenChannel(channel) do_announce = utils.is_option_enabled(options, 'do-announce') do_pin = utils.is_option_enabled(options, 'pin') if do_announce or do_pin: checker.has_any_mod_role(context, print_error=True) prefixed_announce = utils.make_announce( context.guild, announce, do_announce and self.ANNOUNCE_ROLE_NAME) if do_pin and not message.pinned: await message.pin() elif not do_pin and message.pinned: await message.unpin() await message.edit(content=prefixed_announce) await context.send( f"Annonce du sondage d'identifiant `{poll_id}` remplacée par " f"\"`{message.clean_content}`\" : <{message.jump_url}>")
async def organizer( self, context: commands.Context, poll_id: int, organizer: discord.User ): message, channel, emoji_list, is_exclusive, required_role_name, time, previous_organizer = \ await self.get_message_env(poll_id, raise_if_not_found=True) if context.author != previous_organizer: checker.has_any_mod_role(context, print_error=True) if not context.author.permissions_in(channel).send_messages: raise exceptions.ForbiddenChannel(channel) embed = self.build_announce_embed( message.embeds[0].description, is_exclusive, required_role_name, organizer, time, self.guild.roles ) await message.edit(embed=embed) job_id = self.pending_polls[message.id]['_id'] poll_data = {'organizer_id': organizer.id} zbot.db.update_job_data(self.JOBSTORE, job_id, poll_data) self.pending_polls[message.id].update(poll_data) await context.send( f"Organisateur du sondage d'identifiant `{poll_id}` remplacé par " f"`@{organizer.display_name}` : <{message.jump_url}>" )
async def time( self, context: commands.Context, poll_id: int, time: converter.to_datetime ): message, channel, emoji_list, is_exclusive, required_role_name, _, organizer = \ await self.get_message_env(poll_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if not context.author.permissions_in(channel).send_messages: raise exceptions.ForbiddenChannel(channel) if (time - utils.get_current_time()).total_seconds() <= 0: argument_size = converter.humanize_datetime(time) min_argument_size = converter.humanize_datetime(utils.get_current_time()) raise exceptions.UndersizedArgument(argument_size, min_argument_size) embed = self.build_announce_embed( message.embeds[0].description, is_exclusive, required_role_name, organizer, time, self.guild.roles ) await message.edit(embed=embed) job_id = self.pending_polls[message.id]['_id'] scheduler.reschedule_stored_job(job_id, time) # Also updates the next_run_time in db poll_data = {'next_run_time': converter.to_timestamp(time)} self.pending_polls[message.id].update(poll_data) await context.send( f"Date et heure du sondage d'identifiant `{poll_id}` changées pour le " f"`{converter.humanize_datetime(time)}` : <{message.jump_url}>" )
async def time(self, context: commands.Context, lottery_id: int, time: converter.to_datetime): message, channel, emoji, nb_winners, _, organizer = \ await self.get_message_env(lottery_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if (time - utils.get_current_time()).total_seconds() <= 0: argument_size = converter.humanize_datetime(time) min_argument_size = converter.humanize_datetime( utils.get_current_time()) raise exceptions.UndersizedArgument(argument_size, min_argument_size) embed = self.build_announce_embed(emoji, nb_winners, organizer, time, self.guild.roles) await message.edit(embed=embed) job_id = self.pending_lotteries[message.id]['_id'] scheduler.reschedule_stored_job( job_id, time) # Also updates the next_run_time in db lottery_data = {'next_run_time': converter.to_timestamp(time)} self.pending_lotteries[message.id].update(lottery_data) await context.send( f"Date et heure du tirage au sort d'identifiant `{lottery_id}` changées pour le " f"`{converter.humanize_datetime(time)}` : <{message.jump_url}>")
async def emojis(self, context: commands.Context, poll_id: int, emoji_list: converter.to_emoji_list, *, options=""): message, channel, previous_emoji_list, is_exclusive, required_role_name, time, organizer = \ await self.get_message_env(poll_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if not context.author.permissions_in(channel).send_messages: raise exceptions.ForbiddenChannel(channel) if not emoji_list: raise commands.MissingRequiredArgument( context.command.params['emoji_list']) if len(emoji_list) > 20: raise exceptions.OversizedArgument(f"{len(emoji_list)} emojis", "20 emojis") required_role_name = utils.get_option_value(options, 'role') if required_role_name: utils.try_get( # Raise if role does not exist self.guild.roles, error=exceptions.UnknownRole(required_role_name), name=required_role_name) is_exclusive = utils.is_option_enabled(options, 'exclusive') previous_reactions = [ utils.try_get(message.reactions, error=exceptions.MissingEmoji(previous_emoji), emoji=previous_emoji) for previous_emoji in previous_emoji_list ] for previous_reaction in previous_reactions: await previous_reaction.remove(zbot.bot.user) for emoji in emoji_list: await message.add_reaction(emoji) embed = self.build_announce_embed(message.embeds[0].description, is_exclusive, required_role_name, organizer, time, self.guild.roles) await message.edit(embed=embed) job_id = self.pending_polls[message.id]['_id'] poll_data = { 'emoji_codes': list(map(lambda e: e if isinstance(e, str) else e.id, emoji_list)), 'is_exclusive': is_exclusive, 'required_role_name': required_role_name } zbot.db.update_poll_data(job_id, poll_data) self.pending_polls[message.id].update(poll_data) await context.send( f"Émojis du sondage d'identifiant `{poll_id}` mis à jour : <{message.jump_url}>" )
async def assess(self, context: commands.Context, poll_id: int): message, _, _, _, _, _, organizer = await self.get_message_env( poll_id, raise_if_not_found=True ) if context.author.id != organizer: checker.has_any_mod_role(context, print_error=True) await Poll.close_poll(message.id, manual_run=True) await context.send(f"Sondage d'identifiant `{poll_id}` clôturé : <{message.jump_url}>")
async def help(self, context, *, args: str = ""): max_nest_level = utils.get_option_value(args, 'nest') if max_nest_level: try: max_nest_level = int(max_nest_level) except ValueError: raise exceptions.MisformattedArgument(max_nest_level, "valeur entière") else: max_nest_level = self.DEFAULT_HELP_NEST_LEVEL full_command_name = utils.remove_option(args, 'nest') if not full_command_name: # No command specified if max_nest_level < 1: raise exceptions.UndersizedArgument(max_nest_level, 1) await self.display_generic_help(context, max_nest_level) else: # Request help for commands matching the given pattern command_name = full_command_name.split(' ')[-1] command_chain = full_command_name.split(' ')[:-1] matching_commands = utils.get_commands(context, command_chain, command_name) if not matching_commands: raise exceptions.UnknownCommand(command_name) else: # Don't show the helper of all matching commands if one matches exactly if exactly_matching_commands := set( filter(lambda c: c.qualified_name == full_command_name, matching_commands)): matching_commands = exactly_matching_commands # Don't show an error for missing permissions if there is at least one public command public_commands = list( filter(lambda c: not c.hidden, matching_commands)) if len(public_commands) < len( matching_commands): # At least one command is hidden try: # Don't print the error right away checker.has_any_mod_role(context) except exceptions.MissingRoles as error: if not public_commands: # All commands requires permissions raise error # Print the error else: # At least one command is public matching_commands = public_commands # Filter out hidden commands # Show the helper of matching commands sorted_matching_commands = sorted( matching_commands, key=lambda c: c.qualified_name) for command in sorted_matching_commands: if isinstance(command, commands.Group): await self.display_group_help(context, command, max_nest_level) else: await self.display_command_help(context, command)
async def switch(self, context, dest_channel: discord.TextChannel, *, options=""): if context.channel == dest_channel \ or not context.author.permissions_in(dest_channel).send_messages: raise exceptions.ForbiddenChannel(dest_channel) number_option = utils.get_option_value(options, 'number') if number_option is not None: # Value assigned try: messages_number = int(number_option) except ValueError: raise exceptions.MisformattedArgument(number_option, "valeur entière") if messages_number < 0: raise exceptions.UndersizedArgument(messages_number, 0) elif messages_number > 10 and not checker.has_any_mod_role( context, print_error=False): raise exceptions.OversizedArgument(messages_number, 10) elif utils.is_option_enabled(options, 'number', has_value=True): # No value assigned raise exceptions.MisformattedArgument(number_option, "valeur entière") else: # Option not used messages_number = 3 do_ping = utils.is_option_enabled(options, 'ping') do_delete = utils.is_option_enabled(options, 'delete') if do_ping or do_delete: checker.has_any_mod_role(context, print_error=True) messages = await context.channel.history(limit=messages_number + 1 ).flatten() messages.reverse() # Order by oldest first messages.pop() # Remove message used for the command await context.message.delete() if not do_delete: await context.send( f"**Veuillez basculer cette discussion dans le canal {dest_channel.mention} qui " f"serait plus approprié !** 🧹") else: await context.send( f"**La discussion a été déplacée dans le canal {dest_channel.mention} !** " f"({len(messages)} messages supprimés) 🧹") if messages_number != 0: await dest_channel.send( f"**Suite de la discussion de {context.channel.mention}** 💨" ) await self.move_messages(messages, dest_channel, do_ping, do_delete)
async def pick(self, context: commands.Context, lottery_id: int, seed: int = None): message, _, _, _, _, organizer = await self.get_message_env( lottery_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) await Lottery.run_lottery(message.id, seed=seed, manual_run=True) await context.send( f"Tirage au sort d'identifiant `{lottery_id}` exécuté : <{message.jump_url}>" )
async def description(self, context: commands.Context, poll_id: int, description: str): message, channel, _, is_exclusive, required_role_name, time, organizer = \ await self.get_message_env(poll_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if not context.author.permissions_in(channel).send_messages: raise exceptions.ForbiddenChannel(channel) embed = self.build_announce_embed( description, is_exclusive, required_role_name, organizer, time, self.guild.roles) await message.edit(embed=embed) await context.send( f"Description du sondage d'identifiant `{poll_id}` remplacée par " f"\"`{description}`\" : <{message.jump_url}>" )
async def display_group_help(context, group, max_nest_level=DEFAULT_HELP_NEST_LEVEL): # Fetch visible subcommands command_list = Bot.get_command_list(group, max_nest_level) authorized_command_list = list( filter( lambda c: not c.hidden or checker.has_any_mod_role( context, print_error=False), command_list)) sorted_group_commands = sorted(authorized_command_list, key=lambda c: c.qualified_name) # Compute generic command header parent = group.full_parent_name embed_description = f"**Description** : {group.brief}" if group.brief else "" embed_description += ("\n**Alias** : " + ", ".join([ f"`+{(parent + ' ') if parent else ''}{alias}`" for alias in group.aliases ])) if group.aliases else "" # Append group helper embed_description += "\n\n" + "\n".join([ f"• `+{command}` : {command.brief}" for command in sorted_group_commands ]) embed = discord.Embed(title=f"Commande `+{group}`", description=embed_description, color=Bot.EMBED_COLOR) await context.send(embed=embed)
async def display_group_help(context, group, max_nest_level): if group.hidden: checker.has_any_mod_role(context, print_error=True) command_list = Bot.get_command_list(group, max_nest_level) authorized_command_list = list( filter( lambda c: not c.hidden or checker.has_any_mod_role( context, print_error=False), command_list)) sorted_group_commands = sorted(authorized_command_list, key=lambda c: c.name) embed = discord.Embed(title=group.cog.DISPLAY_NAME, description="\n".join([ f"• `+{command}` : {command.brief}" for command in sorted_group_commands ]), color=Bot.EMBED_COLOR) await context.send(embed=embed)
async def display_command_help(context, command): if command.hidden: checker.has_any_mod_role(context) parent = command.full_parent_name embed_description = f"**Description** : {command.brief}" if command.brief else "" embed_description += ("\n**Alias** : " + ", ".join([ f"`+{(parent + ' ') if parent else ''}{alias}`" for alias in command.aliases ])) if command.aliases else "" if command.usage: embed_description += f"\n**Arguments** : `{command.usage}`" embed_description += "\n**Légende** : `<arg>` = obligatoire ; `[arg]` = facultatif ; " \ "`\"arg\"` = argument devant être entouré de guillemets" embed_description += f"\n\n{command.help}" if command.help else "" embed = discord.Embed(title=f"Commande `+{command}`", description=embed_description, color=Bot.EMBED_COLOR) await context.send(embed=embed)
async def organizer(self, context: commands.Context, lottery_id: int, organizer: discord.User): message, channel, emoji, nb_winners, time, previous_organizer = \ await self.get_message_env(lottery_id, raise_if_not_found=True) if context.author != previous_organizer: checker.has_any_mod_role(context, print_error=True) embed = self.build_announce_embed(emoji, nb_winners, organizer, time, self.guild.roles) await message.edit(embed=embed) job_id = self.pending_lotteries[message.id]['_id'] lottery_data = {'organizer_id': organizer.id} zbot.db.update_job_data(self.JOBSTORE, job_id, lottery_data) self.pending_lotteries[message.id].update(lottery_data) await context.send( f"Organisateur du tirage au sort d'identifiant `{lottery_id}` remplacé par " f"`@{organizer.display_name}` : <{message.jump_url}>")
async def cancel(self, context: commands.Context, poll_id: int): message, _, emoji_list, _, _, _, organizer = await self.get_message_env( poll_id, raise_if_not_found=False) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if message: for emoji in emoji_list: reaction = utils.try_get( message.reactions, error=exceptions.MissingEmoji(emoji), emoji=emoji ) await reaction.remove(zbot.bot.user) embed = discord.Embed( title=f"Sondage __annulé__ par {context.author.display_name}", description=message.embeds[0].description if message.embeds[0].description else "", color=Poll.EMBED_COLOR ) embed.set_author(name=f"Organisateur : @{organizer.display_name}", icon_url=organizer.avatar_url) await message.edit(embed=embed) await message.unpin() self.remove_pending_poll(message.id, cancel_job=True) await context.send(f"Sondage d'identifiant `{poll_id}` annulé : <{message.jump_url}>")
async def winners(self, context: commands.Context, lottery_id: int, nb_winners: int): message, channel, emoji, _, time, organizer = \ await self.get_message_env(lottery_id, raise_if_not_found=True) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if nb_winners < 1: raise exceptions.UndersizedArgument(nb_winners, 1) embed = self.build_announce_embed(emoji, nb_winners, organizer, time, self.guild.roles) await message.edit(embed=embed) job_id = self.pending_lotteries[message.id]['_id'] lottery_data = {'nb_winners': nb_winners} zbot.db.update_job_data(self.JOBSTORE, job_id, lottery_data) self.pending_lotteries[message.id].update(lottery_data) await context.send( f"Nombre de gagnants du tirage au sort d'identifiant `{lottery_id}` changé à " f"`{nb_winners}` : <{message.jump_url}>")
async def cancel(self, context: commands.Context, lottery_id: int): message, _, emoji, _, _, organizer = await self.get_message_env( lottery_id, raise_if_not_found=False) if context.author != organizer: checker.has_any_mod_role(context, print_error=True) if message: reaction = utils.try_get(message.reactions, error=exceptions.MissingEmoji(emoji), emoji=emoji) await reaction.remove(zbot.bot.user) embed = discord.Embed( title= f"Tirage au sort __annulé__ par {context.author.display_name}", color=Lottery.EMBED_COLOR) embed.set_author(name=f"Organisateur : @{organizer.display_name}", icon_url=organizer.avatar_url) await message.edit(embed=embed) self.remove_pending_lottery(message.id, cancel_job=True) await context.send( f"Tirage au sort d'identifiant `{lottery_id}` annulé : <{message.jump_url}>" )
async def display_generic_help(context, max_nest_level): bot_display_name = await Bot.get_bot_display_name( context.bot.user, context.guild) embed = discord.Embed(title=f"Commandes de @{bot_display_name}", color=Bot.EMBED_COLOR) commands_by_cog = {} for command in Bot.get_command_list(context.bot, max_nest_level): if not command.hidden or checker.has_any_mod_role( context, print_error=False): commands_by_cog.setdefault(command.cog, []).append(command) for cog in sorted(commands_by_cog, key=lambda c: c.DISPLAY_SEQUENCE): sorted_cog_commands = sorted(commands_by_cog[cog], key=lambda c: c.name) embed.add_field(name=cog.DISPLAY_NAME, value="\n".join([ f"• `+{command}` : {command.brief}" for command in sorted_cog_commands ]), inline=False) embed.set_footer( text="Utilisez +help <commande> pour plus d'informations") await context.send(embed=embed)
async def check_recruitments( self, context, after: converter.to_datetime = converter.to_datetime('1970-01-01'), limit: int = 100, add_reaction=True): if limit < 1: raise exceptions.UndersizedArgument(limit, 1) if (utils.get_current_time() - after).total_seconds() <= 0: argument_size = converter.humanize_datetime(after) max_argument_size = converter.humanize_datetime(utils.get_current_time()) raise exceptions.OversizedArgument(argument_size, max_argument_size) add_reaction and await context.message.add_reaction(self.WORK_IN_PROGRESS_EMOJI) recruitment_channel = self.guild.get_channel(self.RECRUITMENT_CHANNEL_ID) recruitment_announces = await recruitment_channel.history( after=after.replace(tzinfo=None), limit=limit, oldest_first=False # Search in reverse in case the filters limit the results ).flatten() recruitment_announces.reverse() # Reverse again to have oldest match in first place recruitment_announces = list(filter( lambda a: not checker.has_any_mod_role(context, a.author, print_error=False) # Ignore moderation messages and not a.pinned # Ignore pinned messages and not a.type.name == 'pins_add', # Ignore pin notifications recruitment_announces )) await self.check_authors_clan_contact_role(context, recruitment_announces) await self.check_recruitment_announces_uniqueness(context, recruitment_announces) await self.check_recruitment_announces_length(context, recruitment_announces) await self.check_recruitment_announces_embeds(context, recruitment_announces) await self.check_recruitment_announces_timespan(context, recruitment_channel, recruitment_announces) add_reaction and await context.message.remove_reaction(self.WORK_IN_PROGRESS_EMOJI, self.user) add_reaction and await context.message.add_reaction(self.WORK_DONE_EMOJI)
async def start(self, context: commands.Context, announce: str, description: str, dest_channel: discord.TextChannel, emoji_list: converter.to_emoji_list, time: converter.to_future_datetime, *, options=""): # Check arguments if not context.author.permissions_in(dest_channel).send_messages: raise exceptions.ForbiddenChannel(dest_channel) if not emoji_list: raise commands.MissingRequiredArgument( context.command.params['emoji_list']) if len(emoji_list) > 20: raise exceptions.OversizedArgument(f"{len(emoji_list)} emojis", "20 emojis") do_announce = utils.is_option_enabled(options, 'do-announce') do_pin = utils.is_option_enabled(options, 'pin') if do_announce or do_pin: checker.has_any_mod_role(context, print_error=True) required_role_name = utils.get_option_value(options, 'role') if required_role_name: utils.try_get( # Raise if role does not exist self.guild.roles, error=exceptions.UnknownRole(required_role_name), name=required_role_name) # Run command is_exclusive = utils.is_option_enabled(options, 'exclusive') organizer = context.author prefixed_announce = utils.make_announce( context.guild, announce, do_announce and self.ANNOUNCE_ROLE_NAME) embed = self.build_announce_embed(description, is_exclusive, required_role_name, organizer, time, self.guild.roles) message = await dest_channel.send(prefixed_announce, embed=embed) for emoji in emoji_list: await message.add_reaction(emoji) if do_pin: await message.pin() # Register data job_id = scheduler.schedule_stored_job( zbot.db.PENDING_POLLS_COLLECTION, time, self.close_poll, message.id).id poll_data = { 'poll_id': self.get_next_poll_id(), 'message_id': message.id, 'channel_id': dest_channel.id, 'emoji_codes': list(map(lambda e: e if isinstance(e, str) else e.id, emoji_list)), 'organizer_id': organizer.id, 'is_exclusive': is_exclusive, 'required_role_name': required_role_name, } zbot.db.update_poll_data(job_id, poll_data) # Add data managed by scheduler later to avoid updating the database with them poll_data.update({ '_id': job_id, 'next_run_time': converter.to_timestamp(time) }) self.pending_polls[message.id] = poll_data # Confirm command await context.send( f"Sondage d'identifiant `{poll_data['poll_id']}` programmé : <{message.jump_url}>." )
async def inspect_recruitment(self, context, member: typing.Union[discord.Member, str] = None, *, options=''): """Post the status of the recruitment announces monitoring.""" if isinstance(member, str): # Option mistakenly captured as member name options += f" {member}" member = None require_contact_role = not utils.is_option_enabled(options, 'all') recruitment_channel = self.guild.get_channel( self.RECRUITMENT_CHANNEL_ID) zbot.db.update_recruitment_announces( await recruitment_channel.history().flatten()) # Get the record of each author's last announce (deleted or not) author_last_announce_data = {} for announce_data in zbot.db.load_recruitment_announces_data( query={'author': member.id} if member else {}, order=[('time', -1)]): # Associate each author with his/her last announce data if announce_data['author'] not in author_last_announce_data: author_last_announce_data[announce_data['author']] = { 'last_announce_time': announce_data['time'], 'message_id': announce_data['_id'] } # Enhance announces data with additional information min_timespan = datetime.timedelta( # Apply a tolerance of 2 days for players interpreting the 30 days range as "one month". # This is a subtraction because the resulting value is the number of days to wait before posting again. days=self.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN - self.RECRUITMENT_ANNOUNCE_TIMESPAN_TOLERANCE) today = utils.bot_tz_now() for author_id, announce_data in author_last_announce_data.items(): last_announce_time_localized = converter.to_utc( announce_data['last_announce_time']) next_announce_time_localized = last_announce_time_localized + min_timespan author_last_announce_data[author_id] = { 'last_announce_time': last_announce_time_localized, 'next_announce_time': next_announce_time_localized, 'is_time_elapsed': next_announce_time_localized <= today } # Bind the member to the announce data, filter, and order by date asc member_announce_data = { self.guild.get_member(author_id): _ for author_id, _ in author_last_announce_data.items() } filtered_member_announce_data = { author: _ for author, _ in member_announce_data.items() if author is not None # Still member of the server and not checker.has_any_mod_role(context, author, print_error=False ) # Ignore moderation messages and (not require_contact_role or checker.has_role(author, Stats.CLAN_CONTACT_ROLE_NAME)) } ordered_member_announce_data = sorted( filtered_member_announce_data.items(), key=lambda elem: elem[1]['last_announce_time']) # Post the status of announces data if ordered_member_announce_data: await context.send("Statut du suivi des annonces de recrutement :") for block in utils.make_message_blocks([ f"• {author.mention} : {converter.to_human_format(announce_data['last_announce_time'])} " + ("✅" if announce_data['is_time_elapsed'] else f"⏳ (→ {converter.to_human_format(announce_data['next_announce_time'])})" ) for author, announce_data in ordered_member_announce_data ]): await context.send(block) else: await context.send("Aucun suivi enregistré.")