async def check_players_matching_name(context, members, app_id): """Check that all players have a name matching with a player in WoT.""" nonmatching_members = [] try: lower_case_matching_names = [ name.lower() for name in wot_utils.get_players_info( [member.display_name for member in members], app_id).keys() ] except wot_utils.WargammingAPIError: await context.send( "L'API de Wargamming est incapable de vérifier les correspondances de pseudo pour le moment. 💩" ) else: for member in members: # Parse member name as player name with optional clan tag result = utils.PLAYER_NAME_PATTERN.match(member.display_name) if result: # Member name fits, check if it has a match player_name = result.group(1) if player_name.lower() not in lower_case_matching_names: nonmatching_members.append(member) else: # Member name malformed, reject nonmatching_members.append(member) if nonmatching_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'a pas de correspondance de pseudo sur WoT." for member in nonmatching_members ]): await context.send(block) else: await context.send( "Tous les joueurs ont une correspondance de pseudo sur WoT. 👌" ) return nonmatching_members
async def check_contacts_recruiting_permissions(context, contacts_by_clan, app_id): """Check that all clan contacts still have recruiting permissions.""" disbanded_members, demoted_members = [], [] for clan_tag, contacts in contacts_by_clan.items(): for member in contacts: if ' ' in member.display_name: # Missing clan tag handled by Admin.check_contacts_clan_tag player_name = member.display_name.split(' ')[0] player_id, _ = await Stats.get_player_id( player_name, app_id) if player_id: # Non-matching name handled by Admin.check_players_matching_name clan_member_infos = await Stats.get_clan_member_infos( player_id, app_id) real_clan_tag = clan_member_infos and clan_member_infos[ 'tag'] clan_position = clan_member_infos and clan_member_infos[ 'position'] if not clan_member_infos or real_clan_tag != clan_tag.upper( ): disbanded_members.append((member, clan_tag)) elif clan_position not in [ "Commandant", "Commandant en second", "Officier du personnel", "Recruteur" ]: demoted_members.append((member, real_clan_tag)) await context.send( f"Le joueur {member.mention} n'a plus les permissions " f"de recrutement au sein du clan [{real_clan_tag}]." ) if disbanded_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} a quitté le clan [{clan_tag}]." for member, clan_tag in disbanded_members ]): await context.send(block) if demoted_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'a plus les permissions de recrutement au sein du clan [{real_clan_tag}]." for member, real_clan_tag in demoted_members ]): await context.send(block) if not disbanded_members and not demoted_members: await context.send( "Tous les contacts de clan ont encore leurs permissions de recrutement. :ok_hand: " )
async def check_everyone_role(context, members): """Check that all members have at least one role.""" # Ignore first role as it is @everyone if missing_role_members := list(filter(lambda m: len(m.roles) == 1, members)): for block in utils.make_message_blocks([ f"Le joueur {member.mention} ne possède aucun rôle." for member in missing_role_members ]): await context.send(block)
async def check_clans_single_contact(context, contacts_by_clan): """Check that no clan has more than one contact.""" if multiple_contact_clans := dict(filter(lambda i: len(i[1]) > 1, contacts_by_clan.items())): for block in utils.make_message_blocks([ f"Le clan [{clan_tag}] est représenté par {len(contacts)} membres : " f"{', '.join([contact.mention for contact in contacts])}" for clan_tag, contacts in multiple_contact_clans.items() ]): await context.send(block)
async def check_contacts_clan_tag(context, contacts): """Check that all contacts have a clan tag.""" if missing_clan_tag_members := list( filter(lambda c: ' ' not in c.display_name, contacts)): for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'arbore pas de tag de clan." for member in missing_clan_tag_members ]): await context.send(block)
async def check_contacts_recruiting_permissions(context, contacts_by_clan, app_id): """Check that all clan contacts still have recruiting permissions.""" disbanded_members, demoted_members = [], [] for clan_tag, contacts in contacts_by_clan.items(): for member in contacts: result = utils.PLAYER_NAME_PATTERN.match(member.display_name) if result: # Malformed member names handled by check_players_matching_name player_name = result.group(1) _, player_id = wot_utils.get_exact_player_info( player_name, app_id) if player_id: # Non-matching name handled by Admin.check_players_matching_name clan_member_infos = wot_utils.get_clan_member_infos( player_id, app_id) real_clan_tag = clan_member_infos and clan_member_infos[ 'tag'] clan_position = clan_member_infos and clan_member_infos[ 'position'] if not clan_member_infos or real_clan_tag != clan_tag.upper( ): disbanded_members.append((member, clan_tag)) elif clan_position not in [ "Commandant", "Commandant en second", "Officier du personnel", "Recruteur" ]: demoted_members.append((member, real_clan_tag)) if disbanded_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} a quitté le clan [{clan_tag}]." for member, clan_tag in disbanded_members ]): await context.send(block) if demoted_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'a pas les permissions de recrutement au sein du " f"clan [{real_clan_tag}]." for member, real_clan_tag in demoted_members ]): await context.send(block) if not disbanded_members and not demoted_members: await context.send( "Tous les contacts de clan ont encore leurs permissions de recrutement. :ok_hand: " ) return disbanded_members, demoted_members
async def check_authors_clan_contact_role(context, announces): """Check that all announce authors have the clan contact role.""" if missing_clan_contact_role_announces := list(filter( lambda a: not checker.has_guild_role(context.guild, a.author, Stats.CLAN_CONTACT_ROLE_NAME), announces )): for block in utils.make_message_blocks([ f"{announce.author.mention} ne possède pas le rôle @{Stats.CLAN_CONTACT_ROLE_NAME} nécessaire à la " f"publication d'une annonce : {announce.jump_url}" for announce in missing_clan_contact_role_announces ]): await context.send(block)
async def check_recruitment_announces_uniqueness(context, announces): """Check that no two recruitment announces have the same author.""" announces_by_author = {} for announce in announces: announces_by_author.setdefault(announce.author, []).append(announce) if duplicate_announces_by_author := dict(filter(lambda i: len(i[1]) > 1, announces_by_author.items())): message_link_separator = "\n" for block in utils.make_message_blocks([ f"Le joueur {author.mention} a publié {len(announces)} annonces : \n" f"{message_link_separator.join([announce.jump_url for announce in announces])}" for author, announces in duplicate_announces_by_author.items() ]): await context.send(block)
async def check_players_unique_name(context, members): """Check that all players have a unique verified nickname.""" members_by_name = {} for member in members: member_name = member.display_name.split(' ')[0] members_by_name.setdefault(member_name, []).append(member) if duplicate_name_members := dict( filter(lambda i: len(i[1]) > 1, members_by_name.items())): for block in utils.make_message_blocks([ f"Le pseudo vérifié **{member_name}** est utilisé par : " f"{', '.join([member.mention for member in colliding_members])}" for member_name, colliding_members in duplicate_name_members.items() ]): await context.send(block)
async def check_players_unique_name(context, members): """Check that all players have a unique verified nickname.""" members_by_name = {} for member in members: result = utils.PLAYER_NAME_PATTERN.match(member.display_name) if result: # Malformed member names handled by check_players_matching_name member_name = result.group(1) members_by_name.setdefault(member_name, []).append(member) if duplicate_name_members := dict(filter(lambda i: len(i[1]) > 1, members_by_name.items())): for block in utils.make_message_blocks([ f"Le pseudo vérifié **{member_name}** est utilisé par : " f"{', '.join([member.mention for member in colliding_members])}" for member_name, colliding_members in duplicate_name_members.items() ]): await context.send(block)
async def check_contacts_clan_tag(context, contacts): """Check that all contacts have a clan tag.""" missing_clan_tag_members = [] for contact in contacts: result = utils.PLAYER_NAME_PATTERN.match(contact.display_name) if not result or not result.group(3): missing_clan_tag_members.append(contact) if missing_clan_tag_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'arbore pas de tag de clan." for member in missing_clan_tag_members ]): await context.send(block) else: await context.send("Tous les contacts de clan arborent un tag de clan. :ok_hand: ") return missing_clan_tag_members
async def check_everyone_clan_tag(context, members): """Check that no member has an unauthorized clan tag.""" unauthorized_clan_tag_members = [] for member in members: if re.search(r'[ ]*[\[{].{2,5}[\]}][ ]*', member.display_name) and \ not checker.has_role(member, Stats.CLAN_CONTACT_ROLE_NAME): unauthorized_clan_tag_members.append(member) if unauthorized_clan_tag_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} arbore un tag de clan sans être contact de clan." for member in unauthorized_clan_tag_members ]): await context.send(block) else: await context.send("Aucun joueur n'arbore de tag de clan sans être contact de clan. :ok_hand: ") return unauthorized_clan_tag_members
async def check_players_matching_name(context, members, app_id): """Check that all players have a matching player name on WoT.""" def _batch(_array, _batch_size): """ Split an array into an iterable of constant-size batches. """ for _i in range(0, len(_array), _batch_size): yield _array[_i:_i + _batch_size] unmatched_name_members = [] for member_batch in _batch(members, Admin.BATCH_SIZE): # Replace forbidden characters in player names member_names = [ re.sub(r'[^0-9a-zA-Z_]', r'', member.display_name.split(' ')[0]) for member in member_batch ] # Exclude fully non-matching (empty) names member_names = filter(lambda name: name != '', member_names) payload = { 'application_id': app_id, 'search': ','.join(member_names), 'type': 'exact', } response = requests.get( 'https://api.worldoftanks.eu/wot/account/list/', params=payload) response_content = response.json() matched_names = [ player_data['nickname'] for player_data in response_content['data'] ] if response_content['status'] == 'ok' else [] unmatched_name_members += list( filter( lambda m: m.display_name.split(' ')[0].lower() not in [matched_name.lower() for matched_name in matched_names], member_batch)) if unmatched_name_members: for block in utils.make_message_blocks([ f"Le joueur {member.mention} n'a pas de correspondance de pseudo sur WoT." for member in unmatched_name_members ]): await context.send(block) else: await context.send( "Tous les joueurs ont une correspondance de pseudo sur WoT. :ok_hand: " )
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 announce handled by Admin.check_recruitment_announces_uniqueness announces_data_by_author = {} for announce_data in zbot.db.load_recruitment_announces_data( query={'_id': {'$nin': list(map(lambda a: a.id, announces))}}, order=[('time', -1)], ): announces_data_by_author.setdefault(announce_data['author'], []).append( {'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" days=Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN - Admin.RECRUITMENT_ANNOUNCE_TIMESPAN_TOLERANCE ) before_timespan_announces = [] for announce in announces: for announce_data in announces_data_by_author.get(announce.author.id, []): previous_announce_time = announce_data['time'] if previous_announce_time + min_timespan > announce.created_at: before_timespan_announces.append((announce, previous_announce_time)) break # Only check based on the most recent deleted announce if before_timespan_announces: for block in utils.make_message_blocks([ f"L'annonce de {announce.author.mention} a été postée avant le délai minimum de " f"{Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN} jours (dernière publication le " f"{converter.humanize_datetime(previous_announce_time)}). : {announce.jump_url}" for announce, previous_announce_time in before_timespan_announces ]): await context.send(block) else: await context.send( f"Aucune annonce n'a été publiée avant le délai minimum de {Admin.MIN_RECRUITMENT_ANNOUNCE_TIMESPAN} " f"jours. :ok_hand: " ) return before_timespan_announces
async def check_recruitment_announces_embeds(context, announces): """Check that no announce has an embed.""" # Ignore line starting with code block statements discord_link_pattern = re.compile(r'discord(app)?\.(com|gg)') embedded_announces = [] for announce in announces: # Include announces containing Discord links discord_link_count = len(discord_link_pattern.findall(announce.content)) if announce.embeds or discord_link_count: embedded_announces.append((announce, len(announce.embeds) + discord_link_count)) if embedded_announces: for block in utils.make_message_blocks([ f"L'annonce de {announce.author.mention} contient {embed_count} embed(s) : {announce.jump_url}" for announce, embed_count in embedded_announces ]): await context.send(block) else: await context.send( f"Aucune annonce de recrutement ne contient d'embed. :ok_hand: " ) return embedded_announces
async def check_recruitment_announces_length(context, announces): """Check that no recruitment announce is too long.""" too_long_announces = [] for announce in announces: if (apparent_length := Admin.compute_apparent_length(announce) ) > Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH: too_long_announces.append((announce, apparent_length)) if too_long_announces: await context.send( f"Les critères suivants sont utilisés :\n" f"• Chaque ligne compte comme ayant au moins **{Admin.MIN_RECRUITMENT_LINE_LENGTH}** caractères.\n" f"• La longueur apparente maximale est de **{Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH}** caractères.\n_ _" ) for block in utils.make_message_blocks([ f"L'annonce de {announce.author.mention} est d'une longueur apparente de **{apparent_length}** " f"caractères (max {Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH}) : {announce.jump_url}" for announce, apparent_length in too_long_announces ]): await context.send(block) else: await context.send( "Toutes les annonces de recrutement sont de longueur réglementaire. :ok_hand: " ) return too_long_announces @staticmethod def compute_apparent_length(announce): return sum([ max(len(line), Admin.MIN_RECRUITMENT_LINE_LENGTH) for line in announce.content.split('\n') if not re.match(r'^[^a-zA-Z0-9`]+```.*', line
max(len(line), Admin.MIN_RECRUITMENT_LINE_LENGTH) for line in announce.content.split('\n') if not code_block_pattern.match( line ) # Ignore line starting with code block statements ])) > Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH: too_long_announces.append((announce, apparent_length)) if too_long_announces: await context.send( f"Les critères suivants sont utilisés :\n" f"• Chaque ligne compte comme ayant au moins **{Admin.MIN_RECRUITMENT_LINE_LENGTH}** caractères.\n" f"• La longueur apparente maximale est de **{Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH}** caractères.\n_ _" ) for block in utils.make_message_blocks([ f"L'annonce de {announce.author.mention} est d'une longueur apparente de **{apparent_length}** " f"caractères (max {Admin.MAX_RECRUITMENT_ANNOUNCE_LENGTH}) : {announce.jump_url}" for announce, apparent_length in too_long_announces ]): await context.send(block) else: await context.send( "Toutes les annonces de recrutement sont de longueur réglementaire. :ok_hand: " ) @staticmethod async def check_recruitment_announces_embeds(context, announces): """Check that no announce has an embed.""" # Ignore line starting with code block statements discord_link_pattern = re.compile(r'discord(app)?\.(com|gg)') embedded_announces = [] for announce in announces:
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é.")
if changelog := self.get_changelog(current_version): date_iso, description, _ = changelog embed.add_field(name="Date", value=converter.to_human_format( converter.to_datetime(date_iso))) embed.add_field(name="Description", value=description, inline=False) embed.set_footer( text="Utilisez +changelog <version> pour plus d'informations") await context.send(embed=embed) else: # Display all versions versions_data = self.get_versions_data() for block in utils.make_message_blocks([ f"v**{version}** - {converter.to_human_format(versions_data[version]['date'])}\n" f"> {versions_data[version]['description']}" for version in sorted(versions_data, reverse=True) ]): await context.send(block) @commands.command( name='changelog', aliases=['patchnote'], usage="[version]", brief="Affiche les changements d'une version du bot", help= "Si aucune version n'est spécifiée (au format `a.b.c` avec `a`, `b` et `c` des valeurs entières), la " "version courante est utilisée. Les changements affichées ne concernent que les améliorations " "fonctionnelles et les corrections de bug.", ignore_extra=False, )