async def send_automessage(self): # Check if not running above frequency now = utils.bot_tz_now() last_automessage_date = zbot.db.get_metadata('last_automessage_date') if last_automessage_date: last_automessage_date_localized = converter.to_utc(last_automessage_date) if not utils.is_time_almost_elapsed(last_automessage_date_localized, now, self.AUTOMESSAGE_FREQUENCY): logger.debug(f"Prevented sending automessage because running above defined frequency.") return # Get automessages data automessages_data = zbot.db.load_automessages( {'automessage_id': { '$ne': zbot.db.get_metadata('last_automessage_id') # Don't post the same message twice in a row }}, ['automessage_id', 'channel_id', 'message'] ) if not automessages_data: # At most a single automessage exists automessages_data = zbot.db.load_automessages( # Load it anyway {}, ['automessage_id', 'channel_id', 'message'] ) if not automessages_data: # Not automessage exists return # Abort automessage_data = random.choice(automessages_data) automessage_id = automessage_data['automessage_id'] message = automessage_data['message'] channel = self.guild.get_channel(automessage_data['channel_id']) last_channel_message = (await channel.history(limit=1).flatten())[0] # Run halt checks on target channel if last_channel_message.author == self.user: # Avoid spamming an channel # Check if the cooldown between two bot messages has expired last_channel_message_date_localized = converter.to_utc(last_channel_message.created_at) cooldown_expired = last_channel_message_date_localized < now - self.AUTOMESSAGE_COOLDOWN if not cooldown_expired: logger.debug(f"Skipped automessage of id {automessage_id} as cooldown has not expired yet.") return else: # Avoid interrupting conversations is_channel_quiet, attempt_count = False, 0 while not is_channel_quiet: # Wait for the channel to be quiet to send the message now = utils.bot_tz_now() last_channel_message_date_localized = converter.to_utc(last_channel_message.created_at) is_channel_quiet = last_channel_message_date_localized < now - self.AUTOMESSAGE_WAIT if not is_channel_quiet: attempt_count += 1 if attempt_count < 3: logger.debug( f"Pausing automessage of id {automessage_id} for {self.AUTOMESSAGE_WAIT.seconds} " f"seconds while waiting for quietness in target channel." ) await asyncio.sleep(self.AUTOMESSAGE_WAIT.seconds) # Sleep for the duration of the delay else: # After 3 failed attempts, skip logger.debug(f"Skipped automessage of id {automessage_id} after 3 waits for quietness.") return # All checks passed, send the automessage zbot.db.update_metadata('last_automessage_id', automessage_id) zbot.db.update_metadata('last_automessage_date', now) await channel.send(message)
async def celebrate_account_anniversaries(self): # Check if not running above frequency today = utils.bot_tz_now() last_anniversaries_celebration = zbot.db.get_metadata('last_anniversaries_celebration') if last_anniversaries_celebration: last_anniversaries_celebration_localized = converter.to_utc(last_anniversaries_celebration) if last_anniversaries_celebration_localized.date() == converter.to_utc(today).date(): logger.debug(f"Prevented sending anniversaries celebration because running above defined frequency.") return # Get anniversary data self.record_account_creation_dates() account_anniversaries = zbot.db.get_anniversary_account_ids( today, self.MIN_ACCOUNT_CREATION_DATE ) member_anniversaries = {} for years, account_ids in account_anniversaries.items(): for account_id in account_ids: member = self.guild.get_member(account_id) if member and checker.has_role(member, Messaging.PLAYER_ROLE_NAME): member_anniversaries.setdefault(years, []).append(member) # Remove celebration emojis in names from previous anniversaries for member in self.guild.members: if self.CELEBRATION_EMOJI in member.display_name: try: await member.edit( nick=member.display_name.replace(self.CELEBRATION_EMOJI, '').rstrip() ) except (discord.Forbidden, discord.HTTPException): pass # Add celebration emojis for today's anniversaries for year, members in member_anniversaries.items(): for member in members: try: await member.edit( nick=member.display_name + " " + self.CELEBRATION_EMOJI * year ) except (discord.Forbidden, discord.HTTPException): pass # Announce anniversaries (after updating names to refresh the cache) celebration_channel = self.guild.get_channel(self.CELEBRATION_CHANNEL_ID) if member_anniversaries: await celebration_channel.send("**Voici les anniversaires du jour !** 🎂") for year in sorted(member_anniversaries.keys(), reverse=True): for member in member_anniversaries[year]: await celebration_channel.send( f" • {member.mention} fête ses **{year}** ans sur World of Tanks ! 🥳" ) zbot.db.update_metadata('last_anniversaries_celebration', today)
async def check_recruitment_announces_timespan(context, channel, announces): """Check that no announce is re-posted before a given timespan.""" zbot.db.update_recruitment_announces(await channel.history().flatten()) # Get records of all deleted announces # Still existing announces are handled by Admin.check_recruitment_announces_uniqueness author_last_announce_data = {} for announce_data in zbot.db.load_recruitment_announces_data( query={'_id': { '$nin': list(map(lambda a: a.id, announces)) }}, order=[('time', -1)], ): # Associate each author with his/her last delete announce data if announce_data['author'] not in author_last_announce_data: author_last_announce_data[announce_data['author']] = { 'time': announce_data['time'], 'message_id': announce_data['_id'] } # Find all existing announces that have the same author as a recent (but deleted) announce 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=Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN - Admin.RECRUITMENT_ANNOUNCE_TIMESPAN_TOLERANCE) before_timespan_announces = [] for announce in announces: if announce_data := author_last_announce_data.get( announce.author.id): previous_announce_time_localized = converter.to_utc( announce_data['time']) if previous_announce_time_localized \ <= converter.to_utc(announce.created_at) \ < previous_announce_time_localized + min_timespan: before_timespan_announces.append( (announce, previous_announce_time_localized))
async def record_message_count(self, time): message_counts = [] for channel_name in self.DISCUSSION_CHANNELS: channel = utils.try_get(self.guild.channels, name=channel_name) channel_message_count = len(await channel.history( after=converter.to_utc(time - datetime.timedelta(hours=1)).replace( tzinfo=None), limit=999).flatten()) message_counts.append({ 'count': channel_message_count, 'channel_id': channel.id }) zbot.db.insert_timed_message_counts(time, message_counts)
async def graph_members(self, context, *, options=""): days_number, granularity = await self.parse_time_arguments(options) # Load, compute and reshape data today = utils.community_tz_now() time_limit = today - datetime.timedelta(days=days_number) member_counts_data = zbot.db.load_member_counts( {'time': { '$gt': converter.to_utc(time_limit) }}, ['time', 'count']) times, counts = [], [] if granularity == 'hour': # Plot the time and exact value to place the dot accurately for data in member_counts_data: localized_time = converter.to_community_tz( converter.to_utc(data['time'])).replace(tzinfo=None) times.append(localized_time) counts.append(data['count']) elif granularity in ( 'day', 'month', 'year' ): # Only plot the date, and average value to align with the tick counts_by_date = {} for data in member_counts_data: localized_time = converter.to_community_tz( converter.to_utc(data['time'])) counts_by_date.setdefault(localized_time.date(), []).append(data['count']) times.extend(counts_by_date.keys()) counts.extend([ round(sum(date_counts) / len(date_counts)) for date_counts in counts_by_date.values() ]) plt.plot(times, counts, linestyle='-', marker='.', alpha=0.75) self.configure_plot(days_number, time_limit, today, min(counts), max(counts), "Nombre de membres", granularity) await context.send(file=self.render_graph())
async def record_server_stats(self): now = utils.bot_tz_now() last_server_stats_record_date = zbot.db.get_metadata( 'last_server_stats_record') if last_server_stats_record_date: last_server_stats_record_date_localized = converter.to_utc( last_server_stats_record_date) if not utils.is_time_almost_elapsed( last_server_stats_record_date_localized, now, self.SERVER_STATS_RECORD_FREQUENCY, tolerance=datetime.timedelta(minutes=5)): logger.debug( f"Prevented recording server stats because running above define frequency." ) return await self.record_member_count(now) await self.record_message_count(now) zbot.db.update_metadata('last_server_stats_record', now)
async def validate_recruitments(self, context): if not checker.has_guild_role(context.guild, context.author, Stats.CLAN_CONTACT_ROLE_NAME): raise exceptions.MissingRoles([Stats.CLAN_CONTACT_ROLE_NAME]) recruitment_channel = self.guild.get_channel( Admin.RECRUITMENT_CHANNEL_ID) all_recruitment_announces = await recruitment_channel.history( ).flatten() recruitment_announces = list( filter(lambda a: a.author == context.author, all_recruitment_announces)) last_recruitment_announce = recruitment_announces and recruitment_announces[ 0] if not last_recruitment_announce: await context.send("Aucune annonce de recrutement n'a été trouvée." ) else: patched_context = copy(context) patched_context.send = self.mock_send validation_succeeded = True if await Admin.check_recruitment_announces_uniqueness( patched_context, recruitment_announces): validation_succeeded = False await context.send( f"Tu as publié {len(recruitment_announces)} annonces. Une seule est autorisée à la fois." ) if await Admin.check_recruitment_announces_length( patched_context, [last_recruitment_announce]): validation_succeeded = False apparent_length = Admin.compute_apparent_length( last_recruitment_announce) await context.send( f"L'annonce est d'une longueur apparente de **{apparent_length}** caractères (max " f"{Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH}). Réduit sa longueur en retirant du contenu ou en " f"réduisant le nombre de sauts de lignes.") if await Admin.check_recruitment_announces_embeds( patched_context, [last_recruitment_announce]): validation_succeeded = False await context.send( f"Ton annonce contient un embed, ce qui n'est pas autorisé. Utilise un raccourcisseur d'URLs comme " f"<https://tinyurl.com> pour héberger tes liens.") if await Admin.check_recruitment_announces_timespan( patched_context, recruitment_channel, [last_recruitment_announce]): validation_succeeded = False await context.send( f"Ton annonce a été postée avant le délai minimum de {Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN} " f"jours entre deux annonces.") if validation_succeeded: await context.send( f"L'annonce ne présente aucun problème. :ok_hand: ") last_announce_time_localized = converter.to_utc( zbot.db.load_recruitment_announces_data( query={'author': context.author.id}, order=[('time', -1)])[0]['time']) 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=Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN - Admin.RECRUITMENT_ANNOUNCE_TIMESPAN_TOLERANCE) next_announce_time_localized = last_announce_time_localized + min_timespan await context.send( f"Tu pourras à nouveau poster une annonce à partir du " f"{converter.to_human_format(next_announce_time_localized)}.")
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é.")
async def graph_messages(self, context, *, options=""): days_number, granularity = await self.parse_time_arguments( options, default_days_number=2) do_split = utils.is_option_enabled(options, 'split') # Load, compute and reshape data today = utils.community_tz_now() time_limit = today - datetime.timedelta(days=days_number) message_counts_data = zbot.db.load_message_counts( {'time': { '$gt': converter.to_utc(time_limit) }}, ['time', 'count', 'channel_id']) times_by_channel, counts_by_channel = {}, {} if granularity == 'hour': # Plot the time and exact value to place the dot accurately for data in message_counts_data: localized_time = converter.to_community_tz( converter.to_utc(data['time'])).replace(tzinfo=None) times_by_channel.setdefault(data['channel_id'], []).append(localized_time) counts_by_channel.setdefault(data['channel_id'], []).append(data['count']) elif granularity in ( 'day', 'month', 'year' ): # Only plot the date, and average value to align with the tick counts_by_channel_and_time = {} for data in message_counts_data: localized_time = converter.to_community_tz( converter.to_utc(data['time'])) counts_by_channel_and_time.setdefault(data['channel_id'], {}) \ .setdefault(localized_time.date(), []).append(data['count']) for channel, counts_by_date in counts_by_channel_and_time.items(): times_by_channel[channel] = list(counts_by_date.keys()) counts_by_channel[channel] = [ round(sum(date_counts) / len(date_counts)) for date_counts in counts_by_date.values() ] min_count, max_count = float('inf'), -float('inf') if do_split: for channel_id, times, channel_counts in zip( times_by_channel.keys(), times_by_channel.values(), counts_by_channel.values()): channel_name = self.guild.get_channel(channel_id).name plt.plot(times, channel_counts, label=f"#{channel_name}", linestyle='-', marker='.', alpha=0.75) plt.legend() if min(channel_counts) < min_count: min_count = min(channel_counts) if max(channel_counts) > max_count: max_count = max(channel_counts) else: times = list(times_by_channel.values())[0] counts = [0] * len(times) for channel_counts in counts_by_channel.values(): for time_index, channel_count in enumerate(channel_counts): counts[time_index] += channel_count plt.plot(times, counts, linestyle='-', marker='.', alpha=0.75) min_count, max_count = min(counts), max(counts) self.configure_plot(days_number, time_limit, today, min_count, max_count, "Nombre de messages horaires", granularity) await context.send(file=self.render_graph())