class MyCog(commands.Cog): first = app_commands.Group(name='test', description='Test 1') second = app_commands.Group(name='test2', parent=first, description='Test 2') @first.command(name='cmd') async def test_cmd(self, interaction: discord.Interaction) -> None: ... @second.command(name='cmd2') async def test2_cmd(self, interaction: discord.Interaction) -> None: ...
def __init__(self, *args: Any, fallback: Optional[str] = None, **attrs: Any) -> None: super().__init__(*args, **attrs) self.invoke_without_command = True parent = None if self.parent is not None: if isinstance(self.parent, HybridGroup): parent = self.parent.app_command else: raise TypeError( f'HybridGroup parent must be HybridGroup not {self.parent.__class__}' ) guild_ids = attrs.pop('guild_ids', None) or getattr( self.callback, '__discord_app_commands_default_guilds__', None) self.app_command: app_commands.Group = app_commands.Group( name=self.name, description=self.description or self.short_doc or '…', guild_ids=guild_ids, ) # This prevents the group from re-adding the command at __init__ self.app_command.parent = parent self.fallback: Optional[str] = fallback if fallback is not None: command = HybridAppCommand(self) command.name = fallback self.app_command.add_command(command)
class MyGroup(app_commands.Group, name='mygroup'): sub_group = app_commands.Group(name='mysubgroup', description='My sub-group') @sub_group.command() async def my_command(self, interaction: discord.Interaction) -> None: ...
def test_descriptions_group_no_args(): my_group = app_commands.Group(name='mygroup', description='My group') @my_group.command() async def my_command(interaction: discord.Interaction) -> None: """Test slash command""" assert my_command.description == 'Test slash command'
def test_group_with_commands(): my_group = app_commands.Group(name='mygroup', description='My group') @my_group.command() async def my_command(interaction: discord.Interaction) -> None: ... assert my_command.binding is None assert my_command.parent is my_group assert my_group.commands[0] is my_command
def __init__(self, *args: Any, fallback: Optional[str] = None, **attrs: Any) -> None: super().__init__(*args, **attrs) self.invoke_without_command = True self.with_app_command: bool = attrs.pop('with_app_command', True) parent = None if self.parent is not None: if isinstance(self.parent, HybridGroup): parent = self.parent.app_command else: raise TypeError( f'HybridGroup parent must be HybridGroup not {self.parent.__class__}' ) # I would love for this to be Optional[app_commands.Group] # However, Python does not have conditional typing so it's very hard to # make this type depend on the with_app_command bool without a lot of needless repetition self.app_command: app_commands.Group = MISSING self.fallback: Optional[str] = fallback if self.with_app_command: guild_ids = attrs.pop('guild_ids', None) or getattr( self.callback, '__discord_app_commands_default_guilds__', None) guild_only = getattr(self.callback, '__discord_app_commands_guild_only__', False) default_permissions = getattr( self.callback, '__discord_app_commands_default_permissions__', None) nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False) self.app_command = app_commands.Group( name=self.name, description=self.description or self.short_doc or '…', guild_ids=guild_ids, guild_only=guild_only, default_permissions=default_permissions, nsfw=nsfw, ) # This prevents the group from re-adding the command at __init__ self.app_command.parent = parent if fallback is not None: command = HybridAppCommand(self) command.name = fallback self.app_command.add_command(command)
def test_descriptions_group_args(): my_group = app_commands.Group(name='mygroup', description='My group') @my_group.command() async def my_command(interaction: discord.Interaction, arg: str, arg2: int) -> None: """Test slash command Parameters ---------- arg: str Description of arg. This is the second line of the arg description. arg2: int Description of arg2. """ assert my_command.description == 'Test slash command' assert my_command._params[ 'arg'].description == 'Description of arg. This is the second line of the arg description.' assert my_command._params['arg2'].description == 'Description of arg2.'
class MyCog(commands.Cog): my_group = app_commands.Group(name='mygroup', description='My group') @my_group.command() async def my_command(self, interaction: discord.Interaction) -> None: ...
class GuildSync: # The good ol switcheroo guildsync_group = app_commands.Group( name="guildsync", description="Sync your in-game guild roster with server roles", guild_only=True) class SyncGuild: def __init__(self, cog, doc, guild) -> None: self.doc_id = doc["_id"] self.guild = guild self.cog = cog self.ranks_to_role_ids = doc["rank_roles"] self.roles = {} # Reverse hashmap for performance self.role_ids_to_ranks = {} self.id = doc["gid"] self.key = doc["key"] self.tag_role_id = doc["tag_role"] self.tag_role = None self.tag_enabled = doc["enabled"]["tag"] self.guild_name = f"{doc['name']} [{doc['tag']}]" self.ranks_enabled = doc["enabled"]["ranks"] self.base_ep = f"guild/{self.id}/" self.ranks = None self.members = None self.error = None self.last_error = doc.get("error") self.create_roles = guild.me.guild_permissions.manage_roles self.delete_roles = self.create_roles async def fetch_members(self): ep = self.base_ep + "members" results = await self.cog.call_api(endpoint=ep, key=self.key) self.members = {r["name"]: r["rank"] for r in results} async def save(self, *, ranks=False, tag_role=False, edited=False, error=False): update = {} if ranks: roles = {rank: role.id for rank, role in self.roles.items()} update["rank_roles"] = roles if tag_role: update["tag_role"] = self.tag_role_id if edited: update["key"] = self.key update["enabled.ranks"] = self.ranks_enabled update["enabled.tag"] = self.tag_enabled if error: update["error"] = self.error await self.cog.db.guildsyncs.update_one({"_id": self.doc_id}, {"$set": update}) async def delete(self): await self.cog.db.guildsyncs.delete_one({"_id": self.doc_id}) for role_id in self.ranks_to_role_ids.values(): await self.safe_role_delete(self.guild.get_role(role_id)) await self.safe_role_delete(self.guild.get_role(self.tag_role_id)) async def create_role(self, rank=None): if not self.create_roles: return if rank: name = rank else: name = self.guild_name coro = self.guild.create_role(name=name, reason="$guildsync", color=discord.Color( self.cog.embed_color)) try: return await asyncio.wait_for(coro, timeout=5) except discord.Forbidden: self.error = "Bot lacks permission to create roles." self.create_roles = False except asyncio.TimeoutError: self.create_roles = False except discord.HTTPException: pass async def safe_role_delete(self, role): if not self.delete_roles: return if role: try: coro = role.delete(reason="$guildsync - role removed " "or renamed in-game") await asyncio.wait_for(coro, timeout=5) return True except (discord.Forbidden, asyncio.TimeoutError): self.delete_roles = False except discord.HTTPException: pass return False return True async def synchronize_roles(self): if self.tag_enabled: self.tag_role = self.guild.get_role(self.tag_role_id) if not self.tag_role: role = await self.create_role() if role: self.tag_role = role self.tag_role_id = role.id await self.save(tag_role=True) else: if self.tag_role_id: role = self.guild.get_role(self.tag_role_id) if await self.safe_role_delete(role): self.tag_role = None self.tag_role_id = None await self.save(tag_role=True) if self.ranks_enabled: ep = f"guild/{self.id}/ranks" try: ranks = await self.cog.call_api(ep, key=self.key) except APIForbidden: self.error = "Key has in-game leader permissions" return except APIInvalidKey: self.error = "Invalid key. Most likely deleted" return except APIError: self.error = "API error" return self.ranks = {r["id"]: r["order"] for r in ranks} changed = False for rank in self.ranks: if rank in self.ranks_to_role_ids: role = self.guild.get_role( self.ranks_to_role_ids[rank]) if role: self.roles[rank] = role continue role = await self.create_role(rank=rank) if role: self.roles[rank] = role changed = True orphaned = self.ranks_to_role_ids.keys() - self.roles for orphan in orphaned: changed = True role = self.guild.get_role(self.ranks_to_role_ids[orphan]) await self.safe_role_delete(role) self.role_ids_to_ranks = { r.id: k for k, r in self.roles.items() } if changed: await self.save(ranks=True) else: for role_id in self.ranks_to_role_ids.values(): role = self.guild.get_role(role_id) await self.safe_role_delete(role) if self.last_error and not self.error: self.error = None await self.save(error=True) class SyncTarget: @classmethod async def create(cls, cog, member) -> GuildSync.SyncTarget: self = cls() self.member = member doc = await cog.bot.database.get(member, cog) keys = doc.get("keys", []) if not keys: key = doc.get("key") if key: keys.append(key) self.accounts = {key["account_name"] for key in keys} self.is_in_any_guild = False return self async def add_roles(self, roles): try: coro = self.member.add_roles(*roles, reason="$guildsync") await asyncio.wait_for(coro, timeout=5) except (asyncio.TimeoutError, discord.Forbidden): pass async def remove_roles(self, roles): try: coro = self.member.remove_roles(*roles, reason="$guildsync") await asyncio.wait_for(coro, timeout=5) except (asyncio.TimeoutError, discord.Forbidden): pass async def sync_membership(self, sync_guild: GuildSync.SyncGuild): lowest_order = float("inf") highest_rank = None to_add = [] belongs = False current_rank_roles = {} current_tag_role = None for role in self.member.roles: if role.id in sync_guild.role_ids_to_ranks: rank = sync_guild.role_ids_to_ranks[role.id] current_rank_roles[rank] = role elif role.id == sync_guild.tag_role_id: current_tag_role = role for account in self.accounts: if account in sync_guild.members: belongs = True self.is_in_any_guild = True if not sync_guild.ranks_enabled: break if sync_guild.ranks_enabled: rank = sync_guild.members[account] order = sync_guild.ranks.get(rank) if order: if order < lowest_order: lowest_order = order highest_rank = rank if sync_guild.ranks_enabled and highest_rank: if highest_rank not in current_rank_roles: role = sync_guild.roles.get(highest_rank) if role: if role < self.member.guild.me.top_role: to_add.append(role) if sync_guild.tag_enabled and sync_guild.tag_role: if not current_tag_role and belongs: to_add.append(sync_guild.tag_role) if to_add: await self.add_roles(to_add) to_remove = [] for rank in current_rank_roles: if rank != highest_rank: to_remove.append(current_rank_roles[rank]) if not belongs and current_tag_role: to_remove.append(current_tag_role) if to_remove: await self.remove_roles(to_remove) async def guildsync_autocomplete(self, interaction: discord.Interaction, current: str): syncs = await self.db.guildsyncs.find({ "guild_id": interaction.guild.id, "name": prepare_search(current) }).to_list(None) syncs = [self.SyncGuild(self, doc, interaction.guild) for doc in syncs] options = [] for sync in syncs: options.append(Choice(name=sync.guild_name, value=sync.id)) return options @guildsync_group.command(name="diagnose") @app_commands.checks.has_permissions(manage_guild=True, manage_roles=True) @app_commands.describe(sync="The guildsync to debug", user="******") @app_commands.autocomplete(sync=guildsync_autocomplete) async def guildsync_diagnose(self, interaction: discord.Interaction, sync: str, user: discord.User = None): """Diagnose common issues with your guildsync configuration.""" await interaction.response.defer() sync = await self.db.guildsyncs.find_one({ "guild_id": interaction.guild.id, "gid": sync }) if not sync: return await interaction.followup.send("No valid sync chosen") if not interaction.guild.me.guild_permissions.manage_roles: return await interaction.followup.send( "I don't have the manage roles permission") gs = self.SyncGuild(self, sync, interaction.guild) key_ok = await self.verify_leader_permissions(gs.key, gs.id) if not key_ok: return await interaction.followup.send( "The added key is no longer valid. " "Please change the key using /guildsync edit.") if not gs.ranks_enabled and not gs.tag_enabled: return await interaction.followup.send( "Both rank sync and tag role sync are disabled") await gs.synchronize_roles() if user: await gs.fetch_members() user_doc = await self.bot.database.get(user, self) keys = user_doc.get("keys", []) if not keys: key = user_doc.get("key") if key: keys.append(key) for key in keys: if key["account_name"] in gs.members: break else: return await interaction.followup.send( "The provided user " "is not in the guild.") for rank in gs.roles: if gs.roles[rank] > interaction.guild.me.top_role: return await interaction.followup.send( f"The synced role {gs.roles[rank]} is higher on the " "hierarchy than my highest role. I am unable to sync it.") return await interaction.followup.send( "Everything seems to be " "in order! If you're still having trouble, ask for " "help in the support server.") @guildsync_group.command(name="edit") @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( api_key="The API key to use for authorization. Use only if you've " "selected the 'Change API key' operation.", operation="Select the operation.", sync="The guildsync to modify") @app_commands.autocomplete(sync=guildsync_autocomplete) @app_commands.choices(operation=[ Choice(name="Toggle syncing ranks. If disabled, this will delete " "the roles created by the bot.", value="ranks"), Choice(value="guild_role", name="Toggle guild role. If disabled, this will delete " "the roles created by the bot."), Choice(name="Change API key. Make sure to " "fill out the api_key optional argument", value="change_key"), Choice(name="Delete the guildsync", value="delete") ]) async def guildsync_edit(self, interaction: discord.Interaction, sync: str, operation: str, api_key: str = None): """Change settings and delete guildsyncs.""" def bool_to_on(setting): if setting: return "**ON**" return "**OFF**" await interaction.response.defer(ephemeral=True) if operation == "api_key" and not api_key: return await interaction.response.send_message( "You must fill the API key argument to use this operation.", ephemeral=True) sync = await self.db.guildsyncs.find_one({ "guild_id": interaction.guild.id, "gid": sync }) if not sync: return await interaction.followup.send("No valid sync chosen") sync = self.SyncGuild(self, sync, interaction.guild) embed = discord.Embed(title="Guildsync", color=self.embed_color) if operation == "ranks": sync.ranks_enabled = not sync.ranks_enabled elif operation == "guild_role": sync.tag_enabled = not sync.tag_enabled elif operation == "change_key": verified = await self.verify_leader_permissions(api_key, sync.id) if not verified: return await interaction.followup.send( "The API key you provided is invalid.", ephemeral=True) sync.key = api_key elif operation == "delete": await sync.delete() return await interaction.followup.send( content="Sync successfully deleted") await sync.save(edited=True) lines = [ f"Syncing ranks: {bool_to_on(sync.ranks_enabled)}", f"Guild role for members: {bool_to_on(sync.tag_enabled)}" ] description = "\n".join(lines) if sync.last_error: description += f"\n❗ERROR: {sync.last_error}" embed.description = description embed = discord.Embed(title=f"Post-update {sync.guild_name} settings", color=self.embed_color, description=description) await interaction.followup.send(embed=embed) await self.run_guildsyncs(interaction.guild) @guildsync_group.command(name="add") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True, manage_roles=True) @app_commands.checks.bot_has_permissions(manage_roles=True) @app_commands.choices(authentication_method=[ Choice(name="Use your own currently active API key. " "You need to be the guild leader", value="use_key"), Choice(name="Have the bot prompt another user for " "authorization. If selected, fill out user_to_prompt argument", value="prompt_user"), Choice(name="Enter a key. If selected, fill out the api_key argument", value="enter_key") ], sync_type=[ Choice(name="Sync only the in-game ranks", value="ranks"), Choice(name="Give every member of your guild a " "single, guild specific role.", value="guild_role"), Choice( name="Sync both the ranks, and give every " "member a guild specific role", value="ranks_and_role") ]) @app_commands.describe( guild_name="The guild name of the guild " "you wish to sync with.", authentication_method="Select how you want to authenticate " "the leadership of the guild", sync_type="Select how you want the synced roles to behave.", user_to_prompt="The user to prompt for authorization. Use " "only if you've selected it as the authentication_method.", api_key="The api key to use for authorization. " "Use only if you've selected it as the authentication_method.") async def guildsync_add(self, interaction: discord.Interaction, guild_name: str, sync_type: str, authentication_method: str, user_to_prompt: discord.User = None, api_key: str = None): """Sync your in-game guild ranks with Discord. Add a guild.""" if authentication_method == "prompt_user" and not user_to_prompt: return await interaction.response.send_message( "You must specify a user to prompt for authorization", ephemeral=True) if authentication_method == "enter_key" and not api_key: return await interaction.response.send_message( "You must specify an API key to use for authorization", ephemeral=True) endpoint_id = "guild/search?name=" + guild_name.title().replace( ' ', '%20') await interaction.response.defer(ephemeral=True) try: guild_ids = await self.call_api(endpoint_id) guild_id = guild_ids[0] base_ep = f"guild/{guild_id}" info = await self.call_api(base_ep) except (IndexError, APINotFound): return await interaction.followup.send("Invalid guild name") if authentication_method == "enter_key": if not await self.verify_leader_permissions(api_key, guild_id): return await interaction.followup.send( "The provided key is invalid or is missing permissions") key = api_key if authentication_method == "use_key": try: await self.call_api(base_ep + "/members", interaction.user, ["guilds"]) key_doc = await self.fetch_key(interaction.user, ["guilds"]) key = key_doc["key"] except (APIForbidden, APIKeyError): return await interaction.followup.send( "You are not the guild leader.") can_add = await self.can_add_sync(interaction.guild, guild_id) if not can_add: return await interaction.followup.send( "Cannot add this guildsync! You're either syncing with " "this guild already, or you've hit the limit of " f"{GUILDSYNC_LIMIT} active syncs. " f"Check `/guildsync edit`", ephemeral=True) enabled = {"tag": False, "ranks": False} if sync_type == "ranks_and_role": enabled["tag"] = True enabled["ranks"] = True elif sync_type == "ranks": enabled["ranks"] = True else: enabled["tag"] = True guild_info = { "enabled": enabled, "name": info["name"], "tag": info["tag"] } if authentication_method == "prompt_user": try: key_doc = await self.fetch_key(user_to_prompt, ["guilds"]) except APIKeyError: return await interaction.followup.send( "The user does not have a valid key") key = key_doc["key"] try: await self.call_api(base_ep + "/members", key=key) except APIForbidden: return await interaction.followup.send( "The user is missing leader permissions.") try: embed = discord.Embed(title="Guildsync request", color=self.embed_color) embed.description = ( "User {0.name}#{0.discriminator} (`{0.id}`), an " "Administrator in {0.guild.name} server (`{0.guild.id}`) " "is requesting your authorization in order to enable " "Guildsync for the `{1}` guild, of which you are a leader " "of.\nShould you agree, the bot will use your the API key " "that you currently have active to enable Guildsync. If " "the key is deleted at any point the sync will stop " "working.\n**Your key will never be visible to the " "requesting user or anyone else**".format( interaction.user, info["name"])) embed.set_footer( text="Use the reactions below to answer. " "If no response is given within three days this request " "will expire.") view = GuildSyncPromptUserConfirmView(self) msg = await user_to_prompt.send(embed=embed, view=view) prompt_doc = { "guildsync_id": guild_id, "guild_id": interaction.guild.id, "requester_id": interaction.user.id, "created_at": datetime.utcnow(), "message_id": msg.id, "options": guild_info } await self.db.guildsync_prompts.insert_one(prompt_doc) await self.db.guildsync_prompts.create_index( "created_at", expireAfterSeconds=259200) return await interaction.followup.send( "Message successfully sent. You will be " "notified when the user replies.") except discord.HTTPException: return await interaction.followup.send( "Could not send a message to user - " "they most likely have DMs disabled") await self.guildsync_success(interaction.guild, guild_id, destination=interaction.followup, key=key, options=guild_info) async def can_add_sync(self, guild, in_game_guild_id): result = await self.db.guildsyncs.find_one({ "guild_id": guild.id, "gid": in_game_guild_id }) if result: return False count = await self.db.guildsyncs.count_documents( {"guild_id": guild.id}) if count > GUILDSYNC_LIMIT: return False return True async def guildsync_success(self, guild, in_game_guild_id, *, destination, key, options, extra_message=None): if extra_message: await destination.send(extra_message) doc = { "guild_id": guild.id, "gid": in_game_guild_id, "key": key, "rank_roles": {}, "tag_role": None } | options can_add = await self.can_add_sync(guild, in_game_guild_id) if not can_add: return await destination.send( "Cannot add guildsync. You've either reached the limit " f"of {GUILDSYNC_LIMIT} active syncs, or you're trying to add " "one that already exists. See $guildsync edit.") await self.db.guildsyncs.insert_one(doc) guild_doc = await self.bot.database.get(guild, self) sync_doc = guild_doc.get("guildsync", {}) enabled = sync_doc.get("enabled", None) if enabled is None: await self.bot.database.set(guild, {"guildsync.enabled": True}, self) await destination.send("Guildsync succesfully added!") await self.run_guildsyncs(guild) @guildsync_group.command(name="toggle") @app_commands.guild_only() @app_commands.describe( enabled="Enable or disable guildsync for this server") @app_commands.checks.has_permissions(manage_guild=True, manage_roles=True) async def sync_toggle(self, interaction: discord.Interaction, enabled: bool): """Global toggle for guildsync - does not wipe the settings""" guild = interaction.guild await self.bot.database.set_guild(guild, {"guildsync.enabled": enabled}, self) if enabled: msg = ("Guildsync is now enabled. You may still need to " "add guildsyncs using `guildsync add` before it " "is functional.") else: msg = ("Guildsync is now disabled globally on this server. " "Run this command again to enable it.") await interaction.response.send_message(msg) async def guildsync_now(self, ctx): """Force a synchronization""" await self.run_guildsyncs(ctx.guild) @guildsync_group.command(name="purge") @app_commands.guild_only() @app_commands.describe( enabled="Enable or disable purge. You'll be asked to confirm your " "selection afterwards.") @app_commands.checks.has_permissions(manage_guild=True, manage_roles=True, kick_members=True) @app_commands.checks.bot_has_permissions(kick_members=True, manage_roles=True) async def sync_purge(self, interaction: discord.Interaction, enabled: bool): """Toggle kicking of users that are not in any of the synced guilds.""" if enabled: view = ConfirmPurgeView() await interaction.response.send_message( "Members without any other role that have been in the " "server for longer than 48 hours will be kicked during guild " "syncs.", view=view, ephemeral=True) await view.wait() if view.value is None: return await interaction.edit_original_message( content="Timed out.", view=None) elif view.value: await self.bot.database.set(interaction.guild, {"guildsync.purge": enabled}, self) else: await self.bot.database.set(interaction.guild, {"guildsync.purge": enabled}, self) await interaction.response.send_message("Disabled purge.") async def verify_leader_permissions(self, key, guild_id): try: await self.call_api(f"guild/{guild_id}/members", key=key) return True except APIError: return False async def run_guildsyncs(self, guild, *, sync_for=None): guild_doc = await self.bot.database.get(guild, self) guildsync_doc = guild_doc.get("guildsync", {}) enabled = guildsync_doc.get("enabled", False) if not enabled: return purge = guildsync_doc.get("purge", False) cursor = self.db.guildsyncs.find({"guild_id": guild.id}) targets = [] if sync_for: target = targets.append(await self.SyncTarget.create(self, sync_for)) else: for member in guild.members: target = await self.SyncTarget.create(self, member) if target: targets.append(target) async for doc in cursor: try: sync = self.SyncGuild(self, doc, guild) await sync.synchronize_roles() if sync.error: await sync.save(error=True) if sync.ranks_enabled and not sync.roles: continue try: await sync.fetch_members() except APIError: sync.error = "Couldn't fetch guild members." print("failed") await sync.save(error=True) continue for target in targets: await target.sync_membership(sync) except Exception as e: self.log.exception("Exception in guildsync", exc_info=e) if purge: for target in targets: member = target.member membership_duration = (datetime.utcnow() - member.joined_at).total_seconds() if not target.is_in_any_guild: if len(member.roles) == 1 and membership_duration > 172800: try: await member.guild.kick(user=member, reason="$guildsync purge") except discord.Forbidden: pass @tasks.loop(seconds=60) async def guildsync_consumer(self): while True: _, coro = await self.guildsync_queue.get() await asyncio.wait_for(coro, timeout=300) self.guildsync_queue.task_done() await asyncio.sleep(0.5) @guildsync_consumer.before_loop async def before_guildsync_consumer(self): await self.bot.wait_until_ready() @tasks.loop(seconds=60) async def guild_synchronizer(self): cursor = self.bot.database.iter("guilds", {"guildsync.enabled": True}, self, batch_size=10) async for doc in cursor: try: if doc["_obj"]: coro = self.run_guildsyncs(doc["_obj"]) await asyncio.wait_for(coro, timeout=200) except asyncio.CancelledError: return except Exception: pass @guild_synchronizer.before_loop async def before_guild_synchronizer(self): await self.bot.wait_until_ready() def schedule_guildsync(self, guild, priority, *, member=None): coro = self.run_guildsyncs(guild, sync_for=member) self.guildsync_entry_number += 1 self.guildsync_queue.put_nowait( ((priority, self.guildsync_entry_number), coro)) @commands.Cog.listener("on_member_join") async def guildsync_on_member_join(self, member): if member.bot: return guild = member.guild doc = await self.bot.database.get(guild, self) sync = doc.get("guildsync", {}) enabled = sync.get("enabled", False) if enabled: await self.run_guildsyncs(guild, sync_for=member)
class WvwMixin: wvw_group = app_commands.Group(name="wvw", description="WvW related commands") async def world_autocomplete(self, interaction: discord.Interaction, current: str): if not current: return [] current = current.lower() query = prepare_search(current) query = {"name": query} items = await self.db.worlds.find(query).to_list(25) return [Choice(name=it["name"], value=str(it["_id"])) for it in items] @wvw_group.command(name="info") @app_commands.describe( world="World name. Leave blank to use your account's world") @app_commands.autocomplete(world=world_autocomplete) async def wvw_info(self, interaction: discord.Interaction, *, world: str = None): """Info about a world. Defaults to account's world""" user = interaction.user await interaction.response.defer() if not world: endpoint = "account" results = await self.call_api(endpoint, user) wid = results["world"] else: wid = world if not wid: return await interaction.followup.send("Invalid world name") endpoints = [ "wvw/matches?world={0}".format(wid), "worlds?id={0}".format(wid) ] matches, worldinfo = await self.call_multiple(endpoints) linked_worlds = [] worldcolor = "green" for key, value in matches["all_worlds"].items(): if wid in value: worldcolor = key value.remove(wid) linked_worlds = value break if worldcolor == "red": color = discord.Colour.red() elif worldcolor == "green": color = discord.Colour.green() else: color = discord.Colour.blue() linked_worlds = [await self.get_world_name(w) for w in linked_worlds] score = matches["scores"][worldcolor] ppt = 0 victoryp = matches["victory_points"][worldcolor] for m in matches["maps"]: for objective in m["objectives"]: if objective["owner"].lower() == worldcolor: ppt += objective["points_tick"] population = worldinfo["population"] if population == "VeryHigh": population = "Very high" kills = matches["kills"][worldcolor] deaths = matches["deaths"][worldcolor] kd = round((kills / deaths), 2) data = discord.Embed(description="Performance", colour=color) data.add_field(name="Score", value=score) data.add_field(name="Points per tick", value=ppt) data.add_field(name="Victory Points", value=victoryp) data.add_field(name="K/D ratio", value=str(kd), inline=False) data.add_field(name="Population", value=population) if linked_worlds: data.add_field(name="Linked with", value=", ".join(linked_worlds)) data.set_author(name=worldinfo["name"]) if MATPLOTLIB_AVAILABLE: graph = await self.get_population_graph(worldinfo) data.set_image(url=f"attachment://{graph.filename}") return await interaction.followup.send(embed=data, file=graph) await interaction.followup.send(embed=data) @wvw_group.command(name="population_track") @app_commands.describe( world="Specify the name of a World to track the population of, and " "recieve a notification when the specified World is no longer full") @app_commands.autocomplete(world=world_autocomplete) async def wvw_population_track(self, interaction: discord.Interaction, world: str): """Receive a notification when the world is no longer full""" user = interaction.user await interaction.response.defer(ephemeral=True) wid = world if not wid: return await interaction.followup.send("Invalid world name") doc = await self.bot.database.get_user(user, self) if doc and wid in doc.get("poptrack", []): return await interaction.followup.send( "You're already tracking this world") results = await self.call_api("worlds/{}".format(wid)) if results["population"] != "Full": return await interaction.followup.send( "This world is currently not full!") await interaction.followup.send( "You will be notiifed when {} is no longer full " "".format(world.title())) await self.bot.database.set(user, {"poptrack": wid}, self, operator="$push") def population_to_int(self, pop): pops = ["low", "medium", "high", "veryhigh", "full"] return pops.index(pop.lower().replace("_", "")) async def get_population_graph(self, world): cursor = self.db.worldpopulation.find({"world_id": world["id"]}) data = [] async for doc in cursor: data.append((doc["date"], doc["population"])) data.append((datetime.datetime.utcnow(), self.population_to_int(world["population"]))) data.sort(key=lambda x: x[0]) graph = await self.bot.loop.run_in_executor(None, generate_population_graph, data) file = discord.File(graph, "graph.png") return file
class GuildManageMixin: server_group = app_commands.Group(name="server", description="Server management commands", guild_only=True) @server_group.command(name="force_account_names") @app_commands.checks.has_permissions(manage_nicknames=True) @app_commands.checks.bot_has_permissions(manage_nicknames=True) @app_commands.describe( enabled="Enable or disable automatically changing user " "nicknames to match in-game account name") async def server_force_account_names(self, interaction: discord.Interaction, enabled: bool): """Automatically change all server member nicknames to in-game names""" guild = interaction.guild doc = await self.bot.database.get(guild, self) if doc and enabled and doc.get("forced_account_names"): return await interaction.response.send_message( "Forced account names are already enabled") if not enabled: await self.bot.database.set(guild, {"force_account_names": False}, self) return await interaction.response.send_message( "Forced account names disabled") await self.bot.database.set(guild, {"force_account_names": True}, self) await self.force_guild_account_names(guild) await interaction.response.send_message( content="Automatic account names enabled. To disable, use " "`/server forceaccountnames false`\nPlease note that the " "bot cannot change nicknames for roles above the bot.") @server_group.command(name="preview_chat_links") @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( enabled="Enable or disable automatic chat link preview") async def previewchatlinks(self, interaction: discord.Interaction, enabled: bool): """Enable or disable automatic GW2 chat link preview""" guild = interaction.guild doc = await self.bot.database.get(interaction.guild, self) disabled = doc.get("link_preview_disabled", False) if disabled and not enabled: return await interaction.response.send_message( "Chat link preview is aleady disabled.", ephemeral=True) if not disabled and enabled: return await interaction.response.send_message( "Chat link preview is aleady enabled.", ephemeral=True) if not disabled and not enabled: self.chatcode_preview_opted_out_guilds.add(guild.id) return await interaction.response.send_message( "Chat link preview is now disabled.", ephemeral=True) if disabled and enabled: await self.bot.database.set_guild( guild, {"link_preview_disabled": not enabled}, self) await self.bot.database.set_guild( guild, {"link_preview_disabled": not enabled}, self) try: self.chatcode_preview_opted_out_guilds.remove(guild.id) except KeyError: pass return await interaction.response.send_message( "Chat link preview is now enabled.", ephemeral=True) @server_group.command(name="sync_now") @app_commands.checks.has_permissions(manage_guild=True) async def sync_now(self, interaction: discord.Interaction): """Force a sync for any Guildsyncs and Worldsyncs you have""" await interaction.response.send_message("Syncs scheduled!") await self.guildsync_now(interaction) await self.worldsync_now(interaction) @server_group.command(name="api_key_role") @app_commands.checks.has_permissions(manage_roles=True) @app_commands.checks.bot_has_permissions(manage_roles=True) @app_commands.describe( enabled="Enable or disable giving members with an API key a role", role="The role that will be given to members with an API key added") async def server_key_sync(self, interaction: discord.Interaction, enabled: bool, role: discord.Role = None): """A feature to automatically add a role to members that have added an API key to the bot.""" if enabled and not role: return await interaction.response.send_message( "If enabling, you must specify a role " "to give to members with an API key.", ephemeral=True) guild = interaction.guild await self.bot.database.set(guild, { "key_sync.enabled": enabled, "key_sync.role": role.id }, self) if enabled: if not role: return await interaction.response.send_message( "Please specify a role.") await interaction.response.send_message( "Key sync enabled. Members with valid API keys " "will now be given the selected role") return await self.key_sync_guild(guild) await interaction.response.send_message("Key sync disabled.") @tasks.loop(minutes=5) async def key_sync_task(self): cursor = self.bot.database.iter("guilds", {"key_sync.enabled": True}, self) async for doc in cursor: try: guild = doc["_obj"] role = guild.get_role(doc["key_sync"]["role"]) if not role: continue await self.key_sync_guild(guild, role) except asyncio.CancelledError: return except Exception: pass async def key_sync_guild(self, guild, role=None): if not role: doc = await self.bot.database.get(guild, self) enabled = doc.get("key_sync", {}).get("enabled") if not enabled: return role = guild.get_role(doc["key_sync"]["role"]) if not role: return doc = await self.bot.database.get(guild, self) role = guild.get_role(doc["key_sync"]["role"]) if not role: return for member in guild.members: await self.key_sync_user(member, role) async def key_sync_user(self, member, role=None): guild = member.guild if not guild.me.guild_permissions.manage_roles: return if not role: doc = await self.bot.database.get(guild, self) enabled = doc.get("key_sync", {}).get("enabled") if not enabled: return role = guild.get_role(doc["key_sync"]["role"]) if not role: return user_doc = await self.bot.database.get(member, self) has_key = False if user_doc.get("key", {}).get("key"): has_key = True try: if has_key: if role not in member.roles: await member.add_roles(role, reason="/server api_key_role") else: if role in member.roles: await member.remove_roles( role, reason="/server api_key_role is enabled. Member " "lacks a valid API key.") except discord.Forbidden: return @key_sync_task.before_loop async def before_forced_account_names(self): await self.bot.wait_until_ready() async def force_guild_account_names(self, guild): for member in guild.members: try: key = await self.fetch_key(member) name = key["account_name"] if name.lower() not in member.display_name.lower(): await member.edit(nick=name, reason="Force account names - /server") except Exception: pass
class AdminCog(commands.Cog): def __init__(self, bot: SpellBot): self.bot = bot @app_commands.command(name="setup", description="Setup SpellBot on your server.") @tracer.wrap(name="interaction", resource="setup") async def setup(self, interaction: discord.Interaction) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.setup() set_group = app_commands.Group(name="set", description="...") @set_group.command( name="motd", description="Set your server's message of the day. Leave blank to unset.", ) @app_commands.describe(message="Message content") @tracer.wrap(name="interaction", resource="set_motd") async def motd(self, interaction: discord.Interaction, message: Optional[str] = None) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_motd(message) @set_group.command( name="channel_motd", description="Set this channel's message of the day. Leave blank to unset.", ) @tracer.wrap(name="interaction", resource="set_channel_motd") async def channel_motd( self, interaction: discord.Interaction, message: Optional[str] = None, ) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_channel_motd(message) @app_commands.command( name="channels", description="Show the current configurations for channels on your server.", ) @app_commands.describe(page="If there are multiple pages of output, which one?") @tracer.wrap(name="interaction", resource="channels") async def channels(self, interaction: discord.Interaction, page: Optional[int] = 1) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: assert page and page >= 1 await action.channels(page=page) @app_commands.command(name="awards", description="Setup player awards on your server.") @app_commands.describe(page="If there are multiple pages of output, which one?") @tracer.wrap(name="interaction", resource="awards") async def awards(self, interaction: discord.Interaction, page: Optional[int] = 1) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: assert page and page >= 1 await action.awards(page=page) award_group = app_commands.Group(name="award", description="...") @award_group.command(name="add", description="Add a new award level to the list of awards.") @app_commands.describe(count="The number of games needed for this award") @app_commands.describe(role="The role to assign when a player gets this award") @app_commands.describe(message="The message to send players you get this award") @app_commands.describe(repeating="Repeatedly give this award every X games?") @app_commands.describe( remove="Instead of assigning the role, remove it from the player", ) @tracer.wrap(name="interaction", resource="award_add") async def award_add( self, interaction: discord.Interaction, count: int, role: discord.Role, message: str, repeating: Optional[bool] = False, remove: Optional[bool] = False, ) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.award_add(count, str(role), message, repeating=repeating, remove=remove) @award_group.command( name="delete", description="Delete an existing award level from the server.", ) @app_commands.describe(id="The ID number of the award to delete") @tracer.wrap(name="interaction", resource="award_delete") async def award_delete(self, interaction: discord.Interaction, id: int) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.award_delete(id) @set_group.command( name="default_seats", description="Set the default number of seats for new games in this channel.", ) @app_commands.describe(seats="Default number of seats") @app_commands.choices( seats=[ Choice(name="2", value=2), Choice(name="3", value=3), Choice(name="4", value=4), ], ) @tracer.wrap(name="interaction", resource="set_default_seats") async def default_seats(self, interaction: discord.Interaction, seats: int) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_default_seats(seats) @set_group.command( name="auto_verify", description="Should posting in this channel automatically verify users?", ) @app_commands.describe(setting="Setting") @tracer.wrap(name="interaction", resource="set_auto_verify") async def auto_verify(self, interaction: discord.Interaction, setting: bool) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_auto_verify(setting) @set_group.command( name="verified_only", description="Should only verified users be allowed to post in this channel?", ) @app_commands.describe(setting="Setting") @tracer.wrap(name="interaction", resource="set_verified_only") async def verified_only(self, interaction: discord.Interaction, setting: bool) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_verified_only(setting) @set_group.command( name="unverified_only", description="Should only unverified users be allowed to post in this channel?", ) @app_commands.describe(setting="Setting") @tracer.wrap(name="interaction", resource="set_unverified_only") async def unverified_only(self, interaction: discord.Interaction, setting: bool) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_unverified_only(setting) @app_commands.command(name="info", description="Request a DM with full game information.") @app_commands.describe(game_id="SpellBot ID of the game") @tracer.wrap(name="interaction", resource="info") async def info(self, interaction: discord.Interaction, game_id: str) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.info(game_id) @set_group.command( name="voice_category", description="Set the voice category prefix for games in this channel.", ) @app_commands.describe(prefix="Setting") @tracer.wrap(name="interaction", resource="set_voice_category") async def voice_category(self, interaction: discord.Interaction, prefix: str) -> None: add_span_context(interaction) async with AdminAction.create(self.bot, interaction) as action: await action.set_voice_category(prefix)
class CommerceMixin: tp_group = app_commands.Group(name="tp", description="Trading post related commands") gem_group = app_commands.Group(name="gem", description="Gem related commands") @tp_group.command(name="selling") async def tp_selling(self, interaction: discord.Interaction): """Show current selling transactions""" await interaction.response.defer() embed = await self.get_tp_embed(interaction, "sells") if not embed: return await interaction.followup.send(embed=embed) @tp_group.command(name="buying") async def tp_buying(self, interaction: discord.Interaction): """Show current buying transactions""" await interaction.response.defer() embed = await self.get_tp_embed(interaction, "buys") if not embed: return await interaction.followup.send(embed=embed) async def get_tp_embed(self, interaction, state): endpoint = "commerce/transactions/current/" + state doc = await self.fetch_key(interaction.user, ["tradingpost"]) results = await self.call_api(endpoint, key=doc["key"]) data = discord.Embed(description='Current ' + state, colour=await self.get_embed_color(interaction)) data.set_author(name=f'Transaction overview of {doc["account_name"]}') data.set_thumbnail(url=("https://wiki.guildwars2.com/" "images/thumb/d/df/Black-Lion-Logo.png/" "300px-Black-Lion-Logo.png")) data.set_footer(text="Black Lion Trading Company") results = results[:20] # Only display 20 most recent transactions item_id = "" dup_item = {} # Collect listed items for result in results: item_id += str(result["item_id"]) + "," if result["item_id"] not in dup_item: dup_item[result["item_id"]] = len(dup_item) # Get information about all items, doesn't matter if string ends with , endpoint_listing = "commerce/listings?ids={0}".format(str(item_id)) # Call API once for all items try: listings = await self.call_api(endpoint_listing) except APIBadRequest: await interaction.followup.send("You don't have any ongoing " "transactions") return None for result in results: index = dup_item[result["item_id"]] price = result["price"] itemdoc = await self.fetch_item(result["item_id"]) quantity = result["quantity"] item_name = itemdoc["name"] offers = listings[index][state] max_price = offers[0]["unit_price"] undercuts = 0 op = operator.lt if state == "buys" else operator.gt for offer in offers: if op(offer["unit_price"], price): break undercuts += offer["listings"] undercuts = "· Undercuts: {}".format( undercuts) if undercuts else "" if quantity == 1: total = "" else: total = " - Total: " + self.gold_to_coins( interaction, quantity * price) data.add_field(name=item_name, value="{} x {}{}\nMax. offer: {} {}".format( quantity, self.gold_to_coins(interaction, price), total, self.gold_to_coins(interaction, max_price), undercuts), inline=False) return data async def tp_autocomplete(self, interaction: discord.Interaction, current: str): if not current: return [] query = prepare_search(current) query = { "name": query, "flags": { "$nin": ["AccountBound", "SoulbindOnAcquire"] } } items = await self.db.items.find(query).to_list(25) items = sorted(items, key=lambda c: c["name"]) return [Choice(name=it["name"], value=str(it["_id"])) for it in items] @tp_group.command(name="price") @app_commands.autocomplete(item=tp_autocomplete) @app_commands.describe( item="Specify the name of an item to check the price of") async def tp_price(self, interaction: discord.Interaction, item: str): """Check price of an item""" await interaction.response.defer() try: commerce = 'commerce/prices/' endpoint = commerce + item results = await self.call_api(endpoint) except APINotFound: return await interaction.followup.send("This item isn't on the TP." ) except APIError: raise choice = await self.db.items.find_one({"_id": int(item)}) buyprice = results["buys"]["unit_price"] sellprice = results["sells"]["unit_price"] itemname = choice["name"] level = str(choice["level"]) rarity = choice["rarity"] itemtype = self.gamedata["items"]["types"][choice["type"]].lower() description = "A level {} {} {}".format(level, rarity.lower(), itemtype.lower()) if buyprice != 0: buyprice = self.gold_to_coins(interaction, buyprice) else: buyprice = "No buy orders" if sellprice != 0: sellprice = self.gold_to_coins(interaction, sellprice) else: sellprice = "No sell orders" embed = discord.Embed(title=itemname, description=description, colour=self.rarity_to_color(rarity)) if "icon" in choice: embed.set_thumbnail(url=choice["icon"]) embed.add_field(name="Buy price", value=buyprice, inline=False) embed.add_field(name="Sell price", value=sellprice, inline=False) embed.set_footer(text=choice["chat_link"]) await interaction.followup.send(embed=embed) @tp_group.command(name="delivery") async def tp_delivery(self, interaction: discord.Interaction): """Show your items awaiting in delivery box""" endpoint = "commerce/delivery/" await interaction.response.defer() doc = await self.fetch_key(interaction.user, ["tradingpost"]) results = await self.call_api(endpoint, key=doc["key"]) data = discord.Embed(description='Current deliveries', colour=await self.get_embed_color(interaction)) data.set_author(name=f'Delivery overview of {doc["account_name"]}') data.set_thumbnail(url="https://wiki.guildwars2.com/" "images/thumb/d/df/Black-Lion-Logo.png" "/300px-Black-Lion-Logo.png") data.set_footer(text="Black Lion Trading Company") coins = results["coins"] items = results["items"] items = items[:20] # Get only first 20 entries item_quantity = [] itemlist = [] if coins == 0: gold = "Currently no coins for pickup." else: gold = self.gold_to_coins(interaction, coins) data.add_field(name="Coins", value=gold, inline=False) counter = 0 if len(items) != 0: for item in items: item_quantity.append(item["count"]) itemdoc = await self.fetch_item(item["id"]) itemlist.append(itemdoc) for item in itemlist: item_name = item["name"] # Get quantity of items quantity = item_quantity[counter] counter += 1 data.add_field(name=item_name, value="x {0}".format(quantity), inline=False) else: if coins == 0: return await interaction.followup.send( "Your delivery box is empty!") data.add_field(name="No current deliveries.", value="Have fun!", inline=False) await interaction.followup.send(embed=data) def gold_to_coins(self, ctx, money): gold, remainder = divmod(money, 10000) silver, copper = divmod(remainder, 100) kwargs = {"fallback": True, "fallback_fmt": " {} "} gold = "{}{}".format(gold, self.get_emoji(ctx, "gold", ** kwargs)) if gold else "" silver = "{}{}".format(silver, self.get_emoji( ctx, "silver", **kwargs)) if silver else "" copper = "{}{}".format(copper, self.get_emoji( ctx, "copper", **kwargs)) if copper else "" return "".join(filter(None, [gold, silver, copper])) def rarity_to_color(self, rarity): return int(self.gamedata["items"]["rarity_colors"][rarity], 0) @gem_group.command(name="price") @app_commands.describe( quantity="The number of gems to evaluate (default is 400)") async def gem_price(self, interaction: discord.Interaction, quantity: int = 400): """Lists current gold/gem exchange prices.""" if quantity <= 1: return await interaction.followup.send( "Quantity must be higher than 1") await interaction.response.defer() gem_price = await self.get_gem_price(quantity) coin_price = await self.get_coin_price(quantity) data = discord.Embed(title="Currency exchange", colour=await self.get_embed_color(interaction)) data.add_field(name="{} gems would cost you".format(quantity), value=self.gold_to_coins(interaction, gem_price), inline=False) data.set_thumbnail(url="https://render.guildwars2.com/file/220061640EC" "A41C0577758030357221B4ECCE62C/502065.png") data.add_field(name="{} gems could buy you".format(quantity), value=self.gold_to_coins(interaction, coin_price), inline=False) await interaction.followup.send(embed=data) async def get_gem_price(self, quantity=400): endpoint = "commerce/exchange/coins?quantity=10000000" results = await self.call_api(endpoint) cost = results['coins_per_gem'] * quantity return cost async def get_coin_price(self, quantity=400): endpoint = "commerce/exchange/gems?quantity={}".format(quantity) results = await self.call_api(endpoint) return results["quantity"] @gem_group.command(name="track") @app_commands.describe(gold="Receive a notification when price of 400 " "gems drops below this amount. Set to 0 to disable") async def gem_track(self, interaction: discord.Interaction, gold: int): """Receive a notification when cost of 400 gems drops below given cost """ if not 0 <= gold <= 500: return await interaction.response.send_message( "Invalid value. Gold may be between 0 and 500", ephemeral=True) price = gold * 10000 await interaction.response.send_message( "You will be notified when price of 400 gems " f"drops below {gold} gold", ephemeral=True) await self.bot.database.set(interaction.user, {"gemtrack": price}, self)
class EvtcMixin: evtc_automation_group = app_commands.Group( name="evtc_automation", description="Character relating to automating EVTC processing") autopost_group = app_commands.Group( name="autopost", description="Automatically post processed EVTC logs uploaded by " "third party utilities", parent=evtc_automation_group) async def get_dpsreport_usertoken(self, user): doc = await self.bot.database.get(user, self) token = doc.get("dpsreport_token") if not token: try: async with self.session.get(TOKEN_URL) as r: data = await r.json() token = data["userToken"] await self.bot.database.set(user, {"dpsreport_token": token}, self) return token except Exception: return None async def upload_log(self, file, user): params = {"json": 1} token = await self.get_dpsreport_usertoken(user) if token: params["userToken"] = token data = aiohttp.FormData() data.add_field("file", await file.read(), filename=file.filename) async with self.session.post(UPLOAD_URL, data=data, params=params) as r: resp = await r.json() error = resp["error"] if error: raise APIError(error) return resp async def find_duplicate_dps_report(self, doc): margin_of_error = datetime.timedelta(seconds=10) doc = await self.db.encounters.find_one({ "boss_id": doc["boss_id"], "players": { "$eq": doc["players"] }, "date": { "$gte": doc["date"] - margin_of_error, "$lt": doc["date"] + margin_of_error }, "start_date": { "$gte": doc["start_date"] - margin_of_error, "$lt": doc["start_date"] + margin_of_error }, }) return True if doc else False async def get_encounter_data(self, encounter_id): async with self.session.get(JSON_URL, params={"id": encounter_id}) as r: return await r.json() async def upload_embed(self, destination, data, permalink): force_emoji = True if not destination else False lines = [] targets = data["phases"][0]["targets"] group_dps = 0 wvw = data["triggerID"] == 1 for target in targets: group_dps += sum(p["dpsTargets"][target][0]["dps"] for p in data["players"]) def get_graph(percentage): bar_count = round(percentage / 5) bars = "" bars += "▀" * bar_count bars += "━" * (20 - bar_count) return bars def get_dps(player): bars = "" dps = player["dps"] if not group_dps or not dps: percentage = 0 else: percentage = round(100 / group_dps * dps) bars = get_graph(percentage) bars += f"` **{dps}** DPS | **{percentage}%** of group DPS" return bars players = [] for player in data["players"]: dps = 0 for target in targets: dps += player["dpsTargets"][target][0]["dps"] player["dps"] = dps players.append(player) players.sort(key=lambda p: p["dps"], reverse=True) for player in players: down_count = player["defenses"][0]["downCount"] prof = self.get_emoji(destination, player["profession"], force_emoji=True) line = f"{prof} **{player['name']}** *({player['account']})*" if down_count: line += ( f" | {self.get_emoji(destination, 'downed', force_emoji=True)}Downed " f"count: **{down_count}**") lines.append(line) dpses = [] charater_name_max_length = 19 for player in players: line = self.get_emoji(destination, player["profession"], fallback=True, fallback_fmt="", force_emoji=True) align = (charater_name_max_length - len(player["name"])) * " " line += "`" + player["name"] + align + get_dps(player) dpses.append(line) dpses.append(f"> Group DPS: **{group_dps}**") color = discord.Color.green( ) if data["success"] else discord.Color.red() minutes, seconds = data["duration"].split()[:2] minutes = int(minutes[:-1]) seconds = int(seconds[:-1]) duration_time = (minutes * 60) + seconds duration = f"**{minutes}** minutes, **{seconds}** seconds" embed = discord.Embed(title="DPS Report", description="Encounter duration: " + duration, url=permalink, color=color) boss_lines = [] for target in targets: target = data["targets"][target] if data["success"]: health_left = 0 else: percent_burned = target["healthPercentBurned"] health_left = 100 - percent_burned health_left = round(health_left, 2) if len(targets) > 1: boss_lines.append(f"**{target['name']}**") boss_lines.append(f"Health: **{health_left}%**") boss_lines.append(get_graph(health_left)) embed.add_field(name="> **BOSS**", value="\n".join(boss_lines)) buff_lines = [] sought_buffs = ["Might", "Fury", "Quickness", "Alacrity", "Protection"] buffs = [] for buff in sought_buffs: for key, value in data["buffMap"].items(): if value["name"] == buff: buffs.append({ "name": value["name"], "id": int(key[1:]), "stacking": value["stacking"] }) break separator = 2 * en_space line = zero_width_space + (en_space * (charater_name_max_length + 6)) icon_line = line blank = self.get_emoji(destination, "blank", force_emoji=True) first = True for buff in sought_buffs: if first and not blank: icon_line = icon_line[:-2] if not first: if blank: icon_line += blank + blank else: icon_line += separator + (en_space * 4) icon_line += self.get_emoji(destination, buff, fallback=True, fallback_fmt="{:1.1}", force_emoji=True) first = False groups = [] for player in players: if player["group"] not in groups: groups.append(player["group"]) if len(groups) > 1: players.sort(key=lambda p: p["group"]) current_group = None for player in players: if "buffUptimes" not in player: continue if len(groups) > 1: if not current_group or player["group"] != current_group: current_group = player["group"] buff_lines.append(f"> **GROUP {current_group}**") line = "`" line = self.get_emoji(destination, player["profession"], fallback=True, fallback_fmt="", force_emoji=True) align = (3 + charater_name_max_length - len(player["name"])) * " " line += "`" + player["name"] + align for buff in buffs: for buff_uptime in player["buffUptimes"]: if buff["id"] == buff_uptime["id"]: uptime = str( round(buff_uptime["buffData"][0]["uptime"], 1)).rjust(5) break else: uptime = "0" if not buff["stacking"]: uptime += "%" line += uptime line += separator + ((6 - len(uptime)) * magic_space) line += '`' buff_lines.append(line.strip()) if not wvw: embed = embed_list_lines(embed, lines, "> **PLAYERS**") if wvw: dpses = dpses[:15] embed = embed_list_lines(embed, dpses, "> **DPS**") embed.add_field(name="> **BUFFS**", value=icon_line) embed = embed_list_lines(embed, buff_lines, zero_width_space) boss = self.gamedata["bosses"].get(str(data["triggerID"])) date_format = "%Y-%m-%d %H:%M:%S %z" date = datetime.datetime.strptime(data["timeEnd"] + "00", date_format) start_date = datetime.datetime.strptime(data["timeStart"] + "00", date_format) date = date.astimezone(datetime.timezone.utc) start_date = start_date.astimezone(datetime.timezone.utc) doc = { "boss_id": data["triggerID"], "start_date": start_date, "date": date, "players": sorted([player["account"] for player in data["players"]]), "permalink": permalink, "success": data["success"], "duration": duration_time } duplicate = await self.find_duplicate_dps_report(doc) if not duplicate: await self.db.encounters.insert_one(doc) embed.timestamp = date embed.set_footer(text="Recorded at", icon_url=self.bot.user.display_avatar.url) if boss: embed.set_author(name=data["fightName"], icon_url=boss["icon"]) return embed @app_commands.command(name="evtc") @app_commands.checks.bot_has_permissions(embed_links=True) @app_commands.describe( file="EVTC file to process. Accepted formats: .evtc, .zip, .zevtc") async def evtc(self, interaction: discord.Interaction, file: discord.Attachment): """Process an EVTC combat log in an attachment""" if not file.filename.endswith(ALLOWED_FORMATS): return await interaction.response.send_message( "The attachment seems not to be of a correct filetype.\n" f"Allowed file extensions: `{', '.join(ALLOWED_FORMATS)}`", ephemeral=True) await interaction.response.defer() await self.process_evtc([file], interaction.user, interaction.followup) @evtc_automation_group.command(name="channel") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True, manage_channels=True) @app_commands.checks.bot_has_permissions(embed_links=True, use_external_emojis=True) @app_commands.describe( enabled="Disable or enable this feature on the specificed channel", channel="The target channel", autodelete="Delete original message after processing the EVTC log") async def evtc_channel(self, interaction: discord.Interaction, enabled: bool, channel: discord.TextChannel, autodelete: bool): """Sets a channel to be automatically used to process EVTC logs posted within""" doc = await self.bot.database.get(channel, self) enabled = not doc.get("evtc.enabled", False) await self.bot.database.set(channel, { "evtc.enabled": enabled, "evtc.autodelete": autodelete }, self) if enabled: msg = ("Automatic EVTC processing enabled. Simply upload the file " f"wish to be processed in {channel.mention}, while " "@mentioning the bot in the same message.. Accepted " f"formats: `{', '.join(ALLOWED_FORMATS)}`\nTo disable, use " "this command again.") if not channel.permissions_for(interaction.guild.me).embed_links: msg += ("I won't be able to process logs without Embed " "Links permission.") else: msg = ("Automatic EVTC processing diasbled") await interaction.response.send_message(msg) def generate_evtc_api_key(self) -> None: return secrets.token_urlsafe(64) async def get_user_evtc_api_key(self, user: discord.User) -> Union[str, None]: doc = await self.db.evtc.api_keys.find_one({"user": user.id}) or {} return doc.get("token", None) @evtc_automation_group.command(name="api_key") @app_commands.describe(operation="The operation to perform") @app_commands.choices(operation=[ Choice(name="View your API key", value="view"), Choice(name="Generate or regenerate your API key", value="generate"), Choice(name="Delete your API key", value="delete") ]) async def evtc_api_key(self, interaction: discord.Interaction, operation: str): """Generate an API key for third-party apps that automatically upload EVTC logs""" await interaction.response.defer(ephemeral=True) existing_key = await self.get_user_evtc_api_key(interaction.user) if operation == "delete": if not existing_key: return await interaction.followup.send( "You don't have an EVTC API key generated.") await self.db.evtc.api_keys.delete_one( {"_id": interaction.user.id}) return await interaction.followup.send( "Your EVTC API key has been deleted.") if operation == "view": if not existing_key: return await interaction.followup.send( "You don't have an EVTC API key generated. Use " "`/evtc api_key generate` to generate one.") return await interaction.followup.send( f"Your EVTC API key is ```{existing_key}```") if operation == "generate": key = self.generate_evtc_api_key() await self.db.evtc.api_keys.insert_one({ "user": interaction.user.id, "token": key }) new = "new " if existing_key else "" return await interaction.followup.send( f"Your {new}new EVTC API key is:\n```{key}```You may use " "it with utilities that automatically upload logs to link " "them with your account, and potentially post them to " "certain channels. See `/evtc_automation` for more\n\nYou may " "revoke the key at any time with `/evtc api_key delete`, or " "regenerate it with `/evtc api_key generate`. Don't share " "this key with anyone.\nYou can also use this " "key without setting any upload destinations. Doing so will " "still append the report links to `/bosses` results") async def get_evtc_notification_channel(self, id, user): await self.db.evtc.channels.find_one({ "channel_id": id, "user": user.id }) @autopost_group.command(name="add_destination") @app_commands.checks.has_permissions(embed_links=True, use_external_emojis=True) @app_commands.checks.bot_has_permissions(embed_links=True, use_external_emojis=True) async def evtc_autoupload_add(self, interaction: discord.Interaction): """Add this channel as a personal destination to autopost EVTC logs to """ await interaction.response.defer(ephemeral=True) channel = interaction.channel key = await self.get_user_evtc_api_key(interaction.user) if not key: return await interaction.followup.send( "You don't have an EVTC API key generated. Use " "`/evtc api_key generate` to generate one. Confused about " "what this is? The aforementioned command includes a tutorial") doc = await self.db.evtc.destinations.find_one( { "user_id": interaction.user.id, "channel_id": channel.id }) or {} if interaction.guild: channel_doc = await self.bot.database.get(channel, self) if channel_doc.get("evtc.disabled", False): return await interaction.followup.send( "This channel is disabled for EVTC processing.") if doc: return await interaction.followup.send( "This channel is already a destination. If you're " "looking to remove it, see " "`/evtc_automation remove_destinations`") results = await self.call_api("account", interaction.user, ["account"]) guild_ids = results.get("guilds") if guild_ids: endpoints = [f"guild/{gid}" for gid in guild_ids] guilds = await self.call_multiple(endpoints) view = EvtcGuildSelectionView(self, guilds) await interaction.followup.send( "If you wish to use this channel to post only " "the logs made while representing a specific guild, select " "them from the list below. Otherwise, click `Next`.", view=view, ephemeral=True) if await view.wait(): return await self.db.evtc.destinations.insert_one({ "user_id": interaction.user.id, "channel_id": channel.id, "guild_ids": view.selected_guilds, "guild_tags": [ guild["tag"] for guild in guilds if guild["id"] in view.selected_guilds ] }) @autopost_group.command(name="remove_destinations") async def evtc_autoupload_remove(self, interaction: discord.Interaction): """Remove chosen EVTC autoupload destinations""" await interaction.response.defer(ephemeral=True) destinations = await self.db.evtc.destinations.find({ "user_id": interaction.user.id }).to_list(None) channels = [ self.bot.get_channel(dest["channel_id"]) for dest in destinations ] if not channels: return await interaction.followup.send( "You don't have any autopost destinations yet.") view = discord.ui.View() view.add_item( EvtcAutouploadDestinationsSelect(self, channels, destinations)) await interaction.followup.send("** **", view=view) async def process_evtc(self, files: list[discord.Attachment], user, destination): embeds = [] for attachment in files: if attachment.filename.endswith(ALLOWED_FORMATS): try: resp = await self.upload_log(attachment, user) data = await self.get_encounter_data(resp["id"]) embeds.append(await self.upload_embed(destination, data, resp["permalink"])) except Exception as e: self.log.exception("Exception processing EVTC log ", exc_info=e) return await destination.send( content="Error processing your log! :x:", ephemeral=True) for embed in embeds: await destination.send(embed=embed) @tasks.loop(seconds=5) async def post_evtc_notifications(self): cursor = self.db.evtc.notifications.find({"posted": False}) async for doc in cursor: try: user = self.bot.get_user(doc["user_id"]) destinations = await self.db.evtc.destinations.find({ "user_id": user.id }).to_list(None) for destination in destinations: destination["channel"] = self.bot.get_channel( destination["channel_id"]) data = await self.get_encounter_data(doc["encounter_id"]) recorded_by = data.get("recordedBy", None) recorded_player_guild = None for player in data["players"]: if player["name"] == recorded_by: recorded_player_guild = player.get("guildID") break embed = await self.upload_embed(None, data, doc["permalink"]) embed.set_footer( text="Autoposted by " f"{user.name}#{user.discriminator}({user.id})." " The bot respects the user's permissions; remove their " "permission to send messages or embed " "links to stop these messsages.") for destination in destinations: try: channel = destination["channel"] if destination[ "guild_ids"] and recorded_by and recorded_player_guild: if destination["guild_ids"]: if recorded_player_guild not in destination[ "guild_ids"]: continue if not channel: continue has_permission = False if guild := channel.guild: members = [ channel.guild.me, guild.get_member(user.id) ] for member in members: if not channel.permissions_for( member).embed_links: break if not channel.permissions_for( member).send_messages: break if not channel.permissions_for( member).use_external_emojis: break else: has_permission = True else: has_permission = True except asyncio.TimeoutError: raise except Exception as e: self.log.exception( "Exception during evtc notificaitons", exc_info=e) continue if has_permission: try: await channel.send(embed=embed) except discord.HTTPException as e: self.log.exception(e) continue except asyncio.CancelledError: raise
class GeneralGuild: guild_group = app_commands.Group(name="guild", description="Guild related commands") async def guild_name_autocomplete(self, interaction: discord.Interaction, current: str): doc = await self.bot.database.get(interaction.user, self) key = doc.get("key", {}) if not key: return [] account_key = key["account_name"].replace(".", "_") async def cache_guild(): try: results = await self.call_api("account", scopes=["account"], key=key["key"]) except APIError: return choices guild_ids = results.get("guilds", []) endpoints = [f"guild/{gid}" for gid in guild_ids] try: guilds = await self.call_multiple(endpoints) except APIError: return choices guild_list = [] for guild in guilds: guild_list.append({"name": guild["name"], "id": guild["id"]}) c = { "last_update": datetime.datetime.utcnow(), "guild_list": guild_list } await self.bot.database.set(interaction.user, {f"guild_cache.{account_key}": c}, self) choices = [] current = current.lower() if interaction.guild: doc = await self.bot.database.get(interaction.guild, self) guild_id = doc.get("guild_ingame") if guild_id: choices.append( Choice(name="Server's default guild", value=guild_id)) doc = await self.bot.database.get(interaction.user, self) if not key: return choices cache = doc.get("guild_cache", {}).get(account_key, {}) if not cache: if not choices: cache = await cache_guild() else: asyncio.create_task(cache_guild()) elif cache["last_update"] < datetime.datetime.utcnow( ) - datetime.timedelta(days=7): asyncio.create_task(cache_guild()) if cache: choices += [ Choice(name=guild["name"], value=guild["id"]) for guild in cache["guild_list"] if current in guild["name"].lower() ] return choices @guild_group.command(name="info") @app_commands.describe(guild="Guild name.") @app_commands.autocomplete(guild=guild_name_autocomplete) async def guild_info(self, interaction: discord.Interaction, guild: str): """General guild stats""" endpoint = "guild/" + guild await interaction.response.defer() try: results = await self.call_api(endpoint, interaction.user, ["guilds"]) except (IndexError, APINotFound): return await interaction.followup.send_message( "Invalid guild name", ephemeral=True) except APIForbidden: return await interaction.followup.send_message( "You don't have enough permissions in game to " "use this command", ephemeral=True) data = discord.Embed(description='General Info about {0}'.format( results["name"]), colour=await self.get_embed_color(interaction)) data.set_author(name="{} [{}]".format(results["name"], results["tag"])) guild_currencies = [ "influence", "aetherium", "resonance", "favor", "member_count" ] for cur in guild_currencies: if cur == "member_count": data.add_field(name='Members', value="{} {}/{}".format( self.get_emoji(interaction, "friends"), results["member_count"], str(results["member_capacity"]))) else: data.add_field(name=cur.capitalize(), value='{} {}'.format( self.get_emoji(interaction, cur), results[cur])) if "motd" in results: data.add_field(name='Message of the day:', value=results["motd"], inline=False) data.set_footer(text='A level {} guild'.format(results["level"])) await interaction.followup.send(embed=data) @guild_group.command(name="members") @app_commands.describe(guild="Guild name.") @app_commands.autocomplete(guild=guild_name_autocomplete) async def guild_members(self, interaction: discord.Interaction, guild: str): """Shows a list of members and their ranks.""" user = interaction.user scopes = ["guilds"] await interaction.response.defer() endpoints = [ f"guild/{guild}", f"guild/{guild}/members", f"guild/{guild}/ranks" ] try: base, results, ranks = await self.call_multiple( endpoints, user, scopes) except (IndexError, APINotFound): return await interaction.followup.send("Invalid guild name", ephemeral=True) except APIForbidden: return await interaction.followup.send( "You don't have enough permissions in game to " "use this command", ephemeral=True) embed = discord.Embed( colour=await self.get_embed_color(interaction), title=base["name"], ) order_id = 1 # For each order the rank has, go through each member and add it with # the current order increment to the embed lines = [] async def get_guild_member_mention(account_name): if not interaction.guild: return "" cursor = self.bot.database.iter( "users", { "$or": [{ "cogs.GuildWars2.key.account_name": account_name }, { "cogs.GuildWars2.keys.account_name": account_name }] }) async for doc in cursor: member = interaction.guild.get_member(doc["_id"]) if member: return member.mention return "" embeds = [] for order in ranks: for member in results: # Filter invited members if member['rank'] != "invited": member_rank = member['rank'] # associate order from /ranks with rank from /members for rank in ranks: if member_rank == rank['id']: if rank['order'] == order_id: mention = await get_guild_member_mention( member["name"]) if mention: mention = f" - {mention}" line = "**{}**{}\n*{}*".format( member['name'], mention, member['rank']) if len(str(lines)) + len(line) < 6000: lines.append(line) else: embeds.append( embed_list_lines(embed, lines, "> **MEMBERS**", inline=True)) lines = [line] embed = discord.Embed( title=base["name"], colour=await self.get_embed_color(interaction)) order_id += 1 embeds.append( embed_list_lines(embed, lines, "> **MEMBERS**", inline=True)) if len(embeds) == 1: return await interaction.followup.send(embed=embed) for i, embed in enumerate(embeds, start=1): embed.set_footer(text="Page {}/{}".format(i, len(embeds))) view = PaginatedEmbeds(embeds, interaction.user) out = await interaction.followup.send(embed=embeds[0], view=view) view.response = out @guild_group.command(name="treasury") @app_commands.describe(guild="Guild name.") @app_commands.autocomplete(guild=guild_name_autocomplete) async def guild_treasury(self, interaction: discord.Interaction, guild: str): """Get list of current and needed items for upgrades""" await interaction.response.defer() endpoints = [f"guild/{guild}", f"guild/{guild}/treasury"] try: base, treasury = await self.call_multiple(endpoints, interaction.user, ["guilds"]) except (IndexError, APINotFound): return await interaction.followup.send("Invalid guild name", ephemeral=True) except APIForbidden: return await interaction.followup.send( "You don't have enough permissions in game to " "use this command", ephemeral=True) embed = discord.Embed(description=zero_width_space, colour=await self.get_embed_color(interaction)) embed.set_author(name=base["name"]) item_counter = 0 amount = 0 lines = [] itemlist = [] for item in treasury: res = await self.fetch_item(item["item_id"]) itemlist.append(res) # Collect amounts if treasury: for item in treasury: current = item["count"] item_name = itemlist[item_counter]["name"] needed = item["needed_by"] for need in needed: amount = amount + need["count"] if amount != current: line = "**{}**\n*{}*".format( item_name, str(current) + "/" + str(amount)) if len(str(lines)) + len(line) < 6000: lines.append(line) amount = 0 item_counter += 1 else: return await interaction.followup.send("Treasury is empty!") embed = embed_list_lines(embed, lines, "> **TREASURY**", inline=True) await interaction.followup.send(embed=embed) @guild_group.command(name="log") @app_commands.describe(log_type="Select the type of log to inspect", guild="Guild name.") @app_commands.choices(log_type=[ Choice(name=it.title(), value=it) for it in ["stash", "treasury", "members"] ]) @app_commands.autocomplete(guild=guild_name_autocomplete) async def guild_log(self, interaction: discord.Interaction, log_type: str, guild: str): """Get log of last 20 entries of stash/treasury/members""" member_list = [ "invited", "joined", "invite_declined", "rank_change", "kick" ] # TODO use account cache to speed this up await interaction.response.defer() endpoints = [f"guild/{guild}", f"guild/{guild}/log/"] try: base, log = await self.call_multiple(endpoints, interaction.user, ["guilds"]) except (IndexError, APINotFound): return await interaction.followup.send("Invalid guild name", ephemeral=True) except APIForbidden: return await interaction.followup.send( "You don't have enough permissions in game to " "use this command", ephemeral=True) data = discord.Embed(description=zero_width_space, colour=await self.get_embed_color(interaction)) data.set_author(name=base["name"]) lines = [] length_lines = 0 for entry in log: if entry["type"] == log_type: time = entry["time"] timedate = datetime.datetime.strptime( time, "%Y-%m-%dT%H:%M:%S.%fZ").strftime('%d.%m.%Y %H:%M') user = entry["user"] if log_type == "stash" or log_type == "treasury": quantity = entry["count"] if entry["item_id"] == 0: item_name = self.gold_to_coins(interaction, entry["coins"]) quantity = "" multiplier = "" else: itemdoc = await self.fetch_item(entry["item_id"]) item_name = itemdoc["name"] multiplier = "x" if log_type == "stash": if entry["operation"] == "withdraw": operator = " withdrew" else: operator = " deposited" else: operator = " donated" line = "**{}**\n*{}*".format( timedate, user + "{} {}{} {}".format( operator, quantity, multiplier, item_name)) if length_lines + len(line) < 5500: length_lines += len(line) lines.append(line) if log_type == "members": entry_string = "" if entry["type"] in member_list: time = entry["time"] timedate = datetime.datetime.strptime( time, "%Y-%m-%dT%H:%M:%S.%fZ").strftime('%d.%m.%Y %H:%M') user = entry["user"] if entry["type"] == "invited": invited_by = entry["invited_by"] entry_string = (f"{invited_by} has " "invited {user} to the guild.") elif entry["type"] == "joined": entry_string = f"{user} has joined the guild." elif entry["type"] == "kick": kicked_by = entry["kicked_by"] if kicked_by == user: entry_string = "{} has left the guild.".format( user) else: entry_string = "{} has been kicked by {}.".format( user, kicked_by) elif entry["type"] == "rank_change": old_rank = entry["old_rank"] new_rank = entry["new_rank"] if "changed_by" in entry: changed_by = entry["changed_by"] entry_string = ( f"{changed_by} has changed " f"the role of {user} from {old_rank} " f"to {new_rank}.") entry_string = ( "{user} changed his " "role from {old_rank} to {new_rank}.") line = "**{}**\n*{}*".format(timedate, entry_string) if length_lines + len(line) < 5500: length_lines += len(line) lines.append(line) if not lines: return await interaction.followup.send( "No {} log entries yet for {}".format(log_type, base["name"])) data = embed_list_lines(data, lines, "> **{0} Log**".format(log_type.capitalize())) await interaction.followup.send(embed=data) @guild_group.command(name="default") @app_commands.describe(guild="Guild name") @app_commands.autocomplete(guild=guild_name_autocomplete) async def guild_default(self, interaction: discord.Interaction, guild: str): """ Set your default guild for guild commands on this server.""" await interaction.response.defer() results = await self.call_api(f"guild/{guild}") await self.bot.database.set_guild(interaction.guild, { "guild_ingame": guild, }, self) await interaction.followup.send( f"Your default guild is now set to {results['name']} for this " "server. All commands from the `guild` command group " "invoked without a specified guild will default to " "this guild. To reset, simply invoke this command " "without specifying a guild")
class KeyMixin: key_group = app_commands.Group(name="key", description="Character related commands") @key_group.command(name="add") @app_commands.describe( token="Generate at https://account.arena.net under Applications tab") async def key_add(self, interaction: discord.Interaction, token: str): """Adds a key and associates it with your discord account""" await interaction.response.defer(ephemeral=True) doc = await self.bot.database.get(interaction.user, self) try: endpoints = ["tokeninfo", "account"] token_info, acc = await self.call_multiple(endpoints, key=token) except APIInactiveError: return await interaction.followup.send( "The API is currently down. " "Try again later. ") except APIError: return await interaction.followup.send("The key is invalid.") key_doc = { "key": token, "account_name": acc["name"], "name": token_info["name"], "permissions": token_info["permissions"] } # at this point we know the key is valid keys = doc.get("keys", []) key = doc.get("key", {}) if not keys and key: # user already had a key but it isn't in keys # (existing user) so add it keys.append(key) if key_doc["key"] in [k["key"] for k in keys]: return await interaction.followup.send( "You have already added this key before.") if len(keys) >= 15: return await interaction.followup.send( "You've reached the maximum limit of " "15 API keys, please remove one before adding " "another") keys.append(key_doc) await self.bot.database.set(interaction.user, { "key": key_doc, "keys": keys }, self) if len(keys) > 1: output = ("Your key was verified and " "added to your list of keys, you can swap between " "them at any time using /key switch.") else: output = ("Your key was verified and " "associated with your account.") all_permissions = ("account", "builds", "characters", "guilds", "inventories", "progression", "pvp", "tradingpost", "unlocks", "wallet") missing = [ x for x in all_permissions if x not in key_doc["permissions"] ] if missing: output += ("\nPlease note that your API key doesn't have the " "following permissions checked: " f"```{', '.join(missing)}```\nSome commands " "will not work. Consider adding a new key with " "those permissions checked.") await interaction.followup.send(output) try: if interaction.guild: await self.worldsync_on_member_join(interaction.user) await self.guildsync_on_member_join(interaction.user) await self.key_sync_user(interaction.user) return for guild in self.bot.guilds: try: if len(guild.members) > 5000: continue if interaction.user not in guild.members: continue member = guild.get_member(interaction.user.id) await self.key_sync_user(member) doc = await self.bot.database.get(guild, self) worldsync = doc.get("worldsync", {}) worldsync_enabled = worldsync.get("enabled", False) if worldsync_enabled: await self.worldsync_on_member_join(member) guildsync = doc.get("sync", {}) if guildsync.get("on", False) and guildsync.get( "setupdone", False): await self.guildsync_on_member_join(member) except Exception: pass except Exception: pass async def key_autocomplete(self, interaction: discord.Interaction, current: str): doc = await self.bot.database.get(interaction.user, self) current = current.lower() choices = [] keys = doc.get("keys", []) key = doc.get("key", {}) if not keys and key: keys.append(key) for key in keys: token_name = f"{key['account_name']} - {key['name']}" choices.append(Choice(name=token_name, value=key["key"])) return [choice for choice in choices if current in choice.name.lower()] @key_group.command(name="remove") @app_commands.describe(token="The API key to remove from your account") @app_commands.autocomplete(token=key_autocomplete) async def key_remove(self, interaction: discord.Interaction, token: str): """Remove selected keys from the bot""" await interaction.response.defer(ephemeral=True) doc = await self.bot.database.get(interaction.user, self) keys = doc.get("keys", []) key = doc.get("key", {}) to_keep = [] if key.get("key") == token: key = {} for k in keys: if k["key"] != token: to_keep.append(k) if key == key and to_keep == keys: return await interaction.followup.send( "No keys were removed. Invalid token") await self.bot.database.set(interaction.user, { "key": key, "keys": to_keep }, self) await interaction.followup.send("Key removed.") @key_group.command(name="info") async def key_info(self, interaction: discord.Interaction): """Information about your api keys""" doc = await self.bot.database.get(interaction.user, self) await interaction.response.defer(ephemeral=True) keys = doc.get("keys", []) key = doc.get("key", {}) if not keys and not key: return await interaction.followup.send( "You have no keys added, you can add one with /key add.") embed = await self.display_keys(interaction, doc, display_active=True, show_tokens=True, reveal_tokens=True) await interaction.followup.send(embed=embed) @key_group.command(name="switch") @app_commands.autocomplete(token=key_autocomplete) async def key_switch(self, interaction: discord.Interaction, token: str): """Swaps between multiple stored API keys.""" doc = await self.bot.database.get(interaction.user, self) keys = doc.get("keys", []) key = doc.get("key", {}) if not keys: return await interaction.response.send_message( "You need to add additional API keys first using /key " "add first.", ephemeral=True) if key["key"] == token: return await interaction.response.send_message( "That key is currently active.", ephemeral=True) for k in keys: if k["key"] == token: break else: return await interaction.response.send_message( "That key is not in your account.", ephemeral=True) await self.bot.database.set(interaction.user, {"key": k}, self) msg = "Swapped to selected key." if key["name"]: msg += " Name : `{}`".format(k["name"]) await interaction.response.send_message(msg, ephemeral=True) async def display_keys(self, interaction: discord.Interaction, doc, *, display_active=False, display_permissions=True, show_tokens=False, reveal_tokens=False): def get_value(key): lines = [] if display_permissions: lines.append("Permissions: " + ", ".join(key["permissions"])) if show_tokens: token = key["key"] if not reveal_tokens: token = token[:7] + re.sub("[a-zA-Z0-9]", r"\*", token[8:]) else: token = f"||{token}||" lines.append(token) return "\n".join(lines) keys = doc.get("keys", []) embed = discord.Embed(title="Your keys", color=await self.get_embed_color(interaction)) embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) if display_active: active_key = doc.get("key", {}) if active_key: name = "**Active key**: {}".format(active_key["account_name"]) token_name = active_key["name"] if token_name: name += " - " + token_name embed.add_field(name=name, value=get_value(active_key)) for counter, key in enumerate(keys, start=1): name = "**{}**: {}".format(counter, key["account_name"]) token_name = key["name"] if token_name: name += " - " + token_name embed.add_field(name=name, value=get_value(key), inline=False) embed.set_footer(text=self.bot.user.name, icon_url=self.bot.user.display_avatar.url) return embed
class NotiifiersMixin: notifier_group = app_commands.Group(name="notifier", description="Notifier Commands") @notifier_group.command(name="daily") @app_commands.checks.has_permissions(manage_guild=True) @app_commands.guild_only() @app_commands.describe( enabled="Enable or disable Daily Notifier. If " "enabling, channel argument must be set", channel="The channel to post to.", pin_message="Toggle whether to " "automatically pin the daily message or not.", behavior="Select additional behavior for " "deleting/editing the message. Leave blank for standard behavior.") @app_commands.choices(behavior=[ Choice( name="Delete the previous day's message and post a new message. " "Causes an unread notification", value="autodelete"), Choice(name="Edit the previous day's message. No unread notification.", value="autoedit") ]) async def daily_notifier(self, interaction: discord.Interaction, enabled: bool, channel: discord.TextChannel = None, pin_message: bool = False, behavior: str = None): """Send daily achievements to a channel every day""" doc = await self.bot.database.get(interaction.guild, self) enabled = doc.get("daily", {}).get("on", False) # IF ENABLED AND NOT CHANNEL if not enabled and not channel: return await interaction.response.send_message( "Daily notifier is aleady disabled. If " "you were trying to enable it, make sure to fill out " "the `channel` argument.", ephemeral=True) if enabled and not channel: await self.bot.database.set(interaction.guild, {"daily.on": False}, self) return await interaction.response.send_message( "Daily notifier disabled.") if not channel.permissions_for(interaction.guild.me).send_messages: return await interaction.response.send_message( "I do not have permissions to send " f"messages to {channel.mention}", ephemeral=True) if not channel.permissions_for(interaction.guild.me).embed_links: return await interaction.response.send_message( "I do not have permissions to embed links in " f"{channel.mention}", ephemeral=True) view = discord.ui.View(timeout=60) view.add_item( DailyCategoriesDropdown(interaction, self, behavior, pin_message, channel)) await interaction.response.send_message("** **", view=view, ephemeral=True) if await view.wait(): return await interaction.followup.edit_message( content="No response in time.", view=None) @notifier_group.command(name="news") @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( enabled="Enable or disable game news notifier. If " "enabling, channel argument must be set", channel="The channel to post to.", mention="The role to ping when posting the notification.") async def newsfeed(self, interaction: discord.Interaction, enabled: bool, channel: discord.TextChannel = None, mention: discord.Role = None): """Automatically sends news from guildwars2.com to a specified channel """ if enabled and not channel: return await interaction.response.send_message( "You must specify a channel.", ephemeral=True) doc = await self.bot.database.get(interaction.guild, self) already_enabled = doc.get("news", {}).get("on", False) if not already_enabled and not channel: return await interaction.response.send_message( "News notifier is aleady disabled. If " "you were trying to enable it, make sure to fill out " "the `channel` argument.", ephemeral=True) if already_enabled and not channel: await self.bot.database.set(interaction.guild, {"news.on": False}, self) return await interaction.response.send_message( "News notifier disabled.", ephemeral=True) if not channel.permissions_for(interaction.guild.me).send_messages: return await interaction.response.send_message( "I do not have permissions to send " f"messages to {channel.mention}", ephemeral=True) if not channel.permissions_for(interaction.guild.me).embed_links: return await interaction.response.send_message( "I do not have permissions to embed links in " f"{channel.mention}", ephemeral=True) role_id = mention.id if mention else None settings = { "news.on": True, "news.channel": channel.id, "news.role": role_id } await self.bot.database.set(interaction.guild, settings, self) await interaction.response.send_message( f"I will now send news to {channel.mention}.") @notifier_group.command(name="update") @app_commands.checks.has_permissions(manage_guild=True) @app_commands.guild_only() @app_commands.describe( enabled="Enable or disable game update notifier. If " "enabling, channel argument must be set", channel="The channel to post to.", mention="The role to ping when posting the notification.") async def updatenotifier(self, interaction: discord.Interaction, enabled: bool, channel: discord.TextChannel = None, mention: discord.Role = None): """Send a notification whenever the game is updated""" if enabled and not channel: return await interaction.response.send_message( "You must specify a channel.", ephemeral=True) if not interaction.guild: return await interaction.response.send_message( "This command can only be used in servers at the time.", ephemeral=True) doc = await self.bot.database.get(interaction.guild, self) enabled = doc.get("updates", {}).get("on", False) if not enabled and not channel: return await interaction.response.send_message( "Update notifier is aleady disabled. If " "you were trying to enable it, make sure to fill out " "the `channel` argument.", ephemeral=True) if enabled and not channel: await self.bot.database.set(interaction.guild, {"updates.on": False}, self) return await interaction.response.send_message( "Update notifier disabled.") mention_string = "" if mention: mention = int(mention) if mention == interaction.guild.id: mention = "@everyone" elif role := interaction.guild.get_role(mention): mention_string = role.mention else: mention_string = interaction.guild.get_member(mention).mention settings = { "updates.on": True, "updates.channel": channel.id, "updates.mention": mention_string } await self.bot.database.set(interaction.guild, settings, self) await interaction.response.send_message( f"I will now send update notifications to {channel.mention}.")
class PvpMixin: pvp_group = app_commands.Group(name="pvp", description="PvP related commands") @pvp_group.command(name="stats") async def pvp_stats(self, interaction: discord.Interaction): """Information about your general pvp stats""" await interaction.response.defer() doc = await self.fetch_key(interaction.user, ["pvp"]) results = await self.call_api("pvp/stats", key=doc["key"]) rank = results["pvp_rank"] + results["pvp_rank_rollovers"] totalgamesplayed = sum(results["aggregate"].values()) totalwins = results["aggregate"]["wins"] + results["aggregate"]["byes"] if totalgamesplayed != 0: totalwinratio = int((totalwins / totalgamesplayed) * 100) else: totalwinratio = 0 rankedgamesplayed = sum(results["ladders"]["ranked"].values()) rankedwins = results["ladders"]["ranked"]["wins"] + \ results["ladders"]["ranked"]["byes"] if rankedgamesplayed != 0: rankedwinratio = int((rankedwins / rankedgamesplayed) * 100) else: rankedwinratio = 0 rank_id = results["pvp_rank"] // 10 + 1 ranks = await self.call_api("pvp/ranks/{0}".format(rank_id)) embed = discord.Embed(colour=await self.get_embed_color(interaction)) embed.add_field(name="Rank", value=rank, inline=False) embed.add_field(name="Total games played", value=totalgamesplayed) embed.add_field(name="Total wins", value=totalwins) embed.add_field(name="Total winratio", value="{}%".format(totalwinratio)) embed.add_field(name="Ranked games played", value=rankedgamesplayed) embed.add_field(name="Ranked wins", value=rankedwins) embed.add_field(name="Ranked winratio", value="{}%".format(rankedwinratio)) embed.set_author(name=doc["account_name"]) embed.set_thumbnail(url=ranks["icon"]) await interaction.followup.send(embed=embed) @pvp_group.command(name="professions") @app_commands.describe( profession="Select a profession to view specific statistics") @app_commands.choices(profession=[ Choice(name=p.title(), value=p) for p in [ "warrior", "guardian", "revenant", "thief", "ranger", "engineer", "elementalist", "necromancer", "mesmer", ] ]) async def pvp_professions(self, interaction: discord.Interaction, profession: str = None): """Information about your pvp profession stats.""" await interaction.response.defer() doc = await self.fetch_key(interaction.user, ["pvp"]) results = await self.call_api("pvp/stats", key=doc["key"]) professions = self.gamedata["professions"].keys() professionsformat = {} if not profession: for profession in professions: if profession in results["professions"]: wins = (results["professions"][profession]["wins"] + results["professions"][profession]["byes"]) total = sum(results["professions"][profession].values()) winratio = int((wins / total) * 100) professionsformat[profession] = { "wins": wins, "total": total, "winratio": winratio } mostplayed = max(professionsformat, key=lambda i: professionsformat[i]['total']) icon = self.gamedata["professions"][mostplayed]["icon"] mostplayedgames = professionsformat[mostplayed]["total"] highestwinrate = max( professionsformat, key=lambda i: professionsformat[i]["winratio"]) highestwinrategames = professionsformat[highestwinrate]["winratio"] leastplayed = min(professionsformat, key=lambda i: professionsformat[i]["total"]) leastplayedgames = professionsformat[leastplayed]["total"] lowestestwinrate = min( professionsformat, key=lambda i: professionsformat[i]["winratio"]) lowestwinrategames = professionsformat[lowestestwinrate][ "winratio"] data = discord.Embed(description="Professions", color=await self.get_embed_color(interaction)) data.set_thumbnail(url=icon) data.add_field(name="Most played profession", value="{0}, with {1}".format( mostplayed.capitalize(), mostplayedgames)) data.add_field(name="Highest winrate profession", value="{0}, with {1}%".format( highestwinrate.capitalize(), highestwinrategames)) data.add_field(name="Least played profession", value="{0}, with {1}".format( leastplayed.capitalize(), leastplayedgames)) data.add_field(name="Lowest winrate profession", value="{0}, with {1}%".format( lowestestwinrate.capitalize(), lowestwinrategames)) data.set_author(name=doc["account_name"]) return await interaction.followup.send(embed=data) wins = (results["professions"][profession]["wins"] + results["professions"][profession]["byes"]) total = sum(results["professions"][profession].values()) winratio = int((wins / total) * 100) color = self.gamedata["professions"][profession]["color"] color = int(color, 0) data = discord.Embed(description=f"Stats for {profession}", colour=color) data.set_thumbnail( url=self.gamedata["professions"][profession]["icon"]) data.add_field(name="Total games played", value="{0}".format(total)) data.add_field(name="Wins", value="{0}".format(wins)) data.add_field(name="Winratio", value="{0}%".format(winratio)) data.set_author(name=doc["account_name"]) await interaction.followup.send(embed=data)
def __new__(cls, *args: Any, **kwargs: Any) -> Self: # For issue 426, we need to store a copy of the command objects # since we modify them to inject `self` to them. # To do this, we need to interfere with the Cog creation process. self = super().__new__(cls) cmd_attrs = cls.__cog_settings__ # Either update the command with the cog provided defaults or copy it. # r.e type ignore, type-checker complains about overriding a ClassVar self.__cog_commands__ = tuple( c._update_copy(cmd_attrs) for c in cls.__cog_commands__) # type: ignore lookup = {cmd.qualified_name: cmd for cmd in self.__cog_commands__} # Register the application commands children: List[Union[app_commands.Group, app_commands.Command[Self, ..., Any]]] = [] if cls.__cog_is_app_commands_group__: group = app_commands.Group( name=cls.__cog_group_name__, description=cls.__cog_group_description__, nsfw=cls.__cog_group_nsfw__, parent=None, guild_ids=getattr(cls, '__discord_app_commands_default_guilds__', None), guild_only=getattr(cls, '__discord_app_commands_guild_only__', False), default_permissions=getattr( cls, '__discord_app_commands_default_permissions__', None), ) else: group = None self.__cog_app_commands_group__ = group # Update the Command instances dynamically as well for command in self.__cog_commands__: setattr(self, command.callback.__name__, command) parent = command.parent if parent is not None: # Get the latest parent reference parent = lookup[parent.qualified_name] # type: ignore # Update our parent's reference to our self parent.remove_command(command.name) # type: ignore parent.add_command(command) # type: ignore if hasattr(command, '__commands_is_hybrid__') and parent is None: app_command: Optional[ Union[app_commands.Group, app_commands.Command[Self, ..., Any]]] = getattr( command, 'app_command', None) if app_command: group_parent = self.__cog_app_commands_group__ app_command = app_command._copy_with(parent=group_parent, binding=self) # The type checker does not see the app_command attribute even though it exists command.app_command = app_command # type: ignore if self.__cog_app_commands_group__: children.append(app_command) for command in cls.__cog_app_commands__: copy = command._copy_with(parent=self.__cog_app_commands_group__, binding=self) # Update set bindings if copy._attr: setattr(self, copy._attr, copy) children.append(copy) self.__cog_app_commands__ = children if self.__cog_app_commands_group__: self.__cog_app_commands_group__.module = cls.__module__ mapping = {cmd.name: cmd for cmd in children} if len(mapping) > 25: raise TypeError( 'maximum number of application command children exceeded') self.__cog_app_commands_group__._children = mapping # type: ignore # Variance issue return self
class SubscriberCog(Cog): subscribe_group = app_commands.Group( name="subscribe", guild_only=True, description="subscribe to maps, players, or members playing") unsubscribe_group = app_commands.Group( name="unsubscribe", guild_only=True, description="unsubscribe from maps, players, or members") def __init__(self, bot: Bot, db: Redis): self.bot: Bot = bot self.db: Redis = db self.long_map_names_lookup: dict[str, str] = {} if self.db.exists(LONG_MAP_NAMES_KEY): self.long_map_names_lookup = self.db.hgetall(LONG_MAP_NAMES_KEY) self.installed_maps: list[str] = [] # noinspection PyProtectedMember if "maps_manager" in Plugin._loaded_plugins: # noinspection PyProtectedMember,PyUnresolvedReferences self.installed_maps = Plugin._loaded_plugins[ "maps_manager"].installed_maps # noinspection PyProtectedMember elif "maps" in Plugin._loaded_plugins: # noinspection PyProtectedMember,PyUnresolvedReferences self.installed_maps = Plugin._loaded_plugins["maps"].logged_maps self.formatted_installed_maps: dict[str, str] = { mapname: mapname for mapname in self.installed_maps } for mapname, long_map_name in self.long_map_names_lookup.items(): if mapname in self.installed_maps and long_map_name.lower( ) != mapname.lower(): self.formatted_installed_maps[ mapname] = f"{long_map_name} ({mapname})" self.known_players: dict[int, str] = self.gather_known_players() self.last_notified_map: Optional[str] = None self.notified_steam_ids: list[int] = [] if not self.bot.intents.presences: self.subscribe_group.remove_command("member") self.unsubscribe_group.remove_command("member") self.bot.remove_listener(self.on_presence_update) super().__init__() def gather_known_players(self) -> dict[int, str]: returned = {} for key in self.db.keys(LAST_USED_NAME_KEY.format("*")): prefix = LAST_USED_NAME_KEY.rsplit("{", maxsplit=1)[0] suffix = LAST_USED_NAME_KEY.rsplit("}", maxsplit=1)[-1] steam_id_candidate = key.replace(prefix, "").replace(suffix, "") if not steam_id_candidate.isdigit(): continue steam_id = int(steam_id_candidate) last_used_name = self.db.get(key) returned[steam_id] = Plugin.clean_text(last_used_name) return returned @subscribe_group.command( name="map", description="Get notified when your favorite maps are played") @app_commands.describe(mapname="the name of the map to subscribe to") @app_commands.guild_only() async def subscribe_map(self, interaction: Interaction, mapname: str): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) stripped_mapname = mapname.strip(" ") if stripped_mapname == "": reply_embed.description = "No mapname provided." await interaction.edit_original_message(embed=reply_embed) return if stripped_mapname not in self.installed_maps: reply_embed.description = f"Map `{stripped_mapname}` is not installed." await interaction.edit_original_message(embed=reply_embed) return db_return_value = self.db.sadd( DISCORD_MAP_SUBSCRIPTION_KEY.format(interaction.user.id), stripped_mapname) if not db_return_value: immediate_reply_message = f"You already were subscribed to map changes for map " \ f"`{self.formatted_installed_maps[stripped_mapname]}`." else: immediate_reply_message = f"You have been subscribed to map changes for map " \ f"`{self.formatted_installed_maps[stripped_mapname]}`." reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_maps = self.subscribed_maps_of(interaction.user.id) formatted_maps = "`, `".join( [self.format_mapname(mapname) for mapname in subscribed_maps]) reply_embed.description = f"{immediate_reply_message}\n" \ f"You are currently subscribed to map changes for: `{formatted_maps}`" await interaction.edit_original_message(embed=reply_embed) def subscribed_maps_of(self, user_id: int) -> list[str]: return self.db.smembers(DISCORD_MAP_SUBSCRIPTION_KEY.format(user_id)) def format_mapname(self, mapname: str) -> str: if mapname in self.formatted_installed_maps: return self.formatted_installed_maps[mapname] if mapname in self.long_map_names_lookup: return f"{self.long_map_names_lookup[mapname]} ({mapname})" return mapname @subscribe_map.autocomplete(name="mapname") async def subscribe_map_autocomplete(self, interaction: Interaction, current: str) \ -> list[app_commands.Choice[str]]: subscribed_maps = self.subscribed_maps_of(interaction.user.id) filtered_candidates = [ mapname for mapname, formatted_long_name in self.formatted_installed_maps.items() if current.lower() in formatted_long_name.lower() and mapname not in subscribed_maps ] filtered_candidates.sort() return [ app_commands.Choice(name=self.formatted_installed_maps[mapname], value=mapname) for mapname in filtered_candidates[:25] ] @subscribe_group.command( name="player", description="Get notified when your favorite players joins the server") @app_commands.describe(player="Name of the player you want to subscribe to" ) @app_commands.guild_only() async def subscribe_player(self, interaction: Interaction, player: str): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) stripped_player_name = player.strip(" ") if stripped_player_name == "": reply_embed.description = "No player name provided." await interaction.edit_original_message(embed=reply_embed) return matching_players = self.find_matching_players(stripped_player_name) if len(matching_players) == 0: reply_embed.description = f"No player matching player name `{stripped_player_name}` found." await interaction.edit_original_message(embed=reply_embed) return if len(matching_players) > 1: matching_player_names = [ self.formatted_last_used_name(steam_id) for steam_id in matching_players ] formatted_player_names = "`, `".join(matching_player_names) reply_embed.description = f"More than one player matching your player name found. " \ f"Players matching `{stripped_player_name}` are:\n" \ f"`{formatted_player_names}`" await interaction.edit_original_message(embed=reply_embed) return matching_steam_id = int(matching_players[0]) db_return_value = self.db.sadd( DISCORD_PLAYER_SUBSCRIPTION_KEY.format(interaction.user.id), matching_steam_id) last_used_name = self.formatted_last_used_name(matching_steam_id) if not db_return_value: immediate_reply_message = f"You already were subscribed to player `{last_used_name}`." else: immediate_reply_message = f"You have been subscribed to player `{last_used_name}`." reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_players = self.subscribed_players_of(interaction.user.id) formatted_players = "`, `".join([ self.formatted_last_used_name(subscribed_steam_id) for subscribed_steam_id in subscribed_players ]) reply_embed.description = f"{immediate_reply_message}\n" \ f"You are currently subscribed to the following players: `{formatted_players}`" await interaction.edit_original_message(embed=reply_embed) def find_matching_players(self, player: str) -> list[int]: if player.isdigit() and int(player) in self.known_players: return [int(player)] matching_steam_ids = [] for steam_id, player_name in self.known_players.items(): if player.lower() in player_name.lower() or player in str( steam_id): matching_steam_ids.append(steam_id) return matching_steam_ids def formatted_last_used_name(self, steam_id: int) -> str: if not self.db.exists(LAST_USED_NAME_KEY.format(steam_id)): return str(steam_id) return Plugin.clean_text( self.db.get(LAST_USED_NAME_KEY.format(steam_id))).replace( "`", r"\`") def subscribed_players_of(self, user_id: int) -> list[int]: player_subscriptions = self.db.smembers( DISCORD_PLAYER_SUBSCRIPTION_KEY.format(user_id)) return [ int(player_steam_id) for player_steam_id in player_subscriptions ] @subscribe_player.autocomplete(name="player") async def subscribe_player_autocomplete(self, interaction: Interaction, current: str) \ -> list[app_commands.Choice[str]]: subscribed_players = self.subscribed_players_of(interaction.user.id) filtered_candidates = [ candidate_steam_id for candidate_steam_id in self.find_matching_players(current) if candidate_steam_id not in subscribed_players ] filtered_candidates.sort() return [ app_commands.Choice(name=self.formatted_last_used_name(steam_id), value=str(steam_id)) for steam_id in filtered_candidates[:25] ] @subscribe_group.command( name="member", description="Get notified when your favorite discord user starts playing" ) @app_commands.describe(member="Discord user you want to subscribe to") @app_commands.guild_only() async def subscribe_member(self, interaction: Interaction, member: Member): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) db_return_value = self.db.sadd( DISCORD_MEMBER_SUBSCRIPTION_KEY.format(interaction.user.id), member.id) if not db_return_value: immediate_reply_message = f"You already were subscribed to Quake Live activities of {member.mention}." else: immediate_reply_message = f"You have been subscribed to Quake Live activities of {member.mention}." reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_users = self.subscribed_users_of(interaction.user.id) formatted_users = ", ".join( [user.mention for user in subscribed_users]) reply_embed.description = f"{immediate_reply_message}\n" \ f"You are currently subscribed to Quake Live activities of: {formatted_users}" await interaction.edit_original_message(embed=reply_embed) def subscribed_users_of(self, user_id: int) -> list[User]: subscribed_users = [] for discord_str_id in self.db.smembers( DISCORD_MEMBER_SUBSCRIPTION_KEY.format(user_id)): discord_id = int(discord_str_id) subscribed_user = self.bot.get_user(discord_id) if subscribed_user is None: continue subscribed_users.append(subscribed_user) return subscribed_users @unsubscribe_group.command(name="map", description="Stop getting notified about a map") @app_commands.describe(mapname="the name of the map to subscribe from") @app_commands.guild_only() async def unsubscribe_map(self, interaction: Interaction, mapname: str): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) stripped_mapname = mapname.strip(" ") if stripped_mapname == "": reply_embed.description = "No mapname provided." await interaction.edit_original_message(embed=reply_embed) return db_return_value = self.db.srem( DISCORD_MAP_SUBSCRIPTION_KEY.format(interaction.user.id), stripped_mapname) if not db_return_value: immediate_reply_message = f"You were not subscribed to map changes for map " \ f"`{self.format_mapname(stripped_mapname)}`. " else: immediate_reply_message = f"You have been unsubscribed from map changes for map " \ f"`{self.format_mapname(stripped_mapname)}`. " reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_maps = self.subscribed_maps_of(interaction.user.id) if len(subscribed_maps) == 0: reply_embed.description = f"{immediate_reply_message}\nYou are no longer subscribed to any map changes." await interaction.edit_original_message(embed=reply_embed) return formatted_maps = "`, `".join( [self.format_mapname(mapname) for mapname in subscribed_maps]) reply_embed.description = f"{immediate_reply_message}\nYou are still subscribed to `{formatted_maps}`" await interaction.edit_original_message(embed=reply_embed) @unsubscribe_map.autocomplete("mapname") async def unsubscribe_map_autocomplete(self, interaction: Interaction, current: str) \ -> list[app_commands.Choice[str]]: subscribed_maps = self.subscribed_maps_of(interaction.user.id) candidates = [ mapname for mapname in subscribed_maps if current.lower() in self.format_mapname(mapname).lower() ] candidates.sort() return [ app_commands.Choice(name=self.format_mapname(mapname), value=mapname) for mapname in candidates[:25] ] @unsubscribe_group.command( name="player", description="Stop getting notified about a player") @app_commands.describe( player="Name of the player you want to unsubscribe from") @app_commands.guild_only() async def unsubscribe_player(self, interaction: Interaction, player: str): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) stripped_player_name = player.strip(" ") if stripped_player_name == "": reply_embed.description = "No player name provided." await interaction.edit_original_message(embed=reply_embed) return matching_players = self.find_matching_players(stripped_player_name) if len(matching_players) == 0: reply_embed.description = f"No player matching player name `{stripped_player_name}` found." await interaction.edit_original_message(embed=reply_embed) return if len(matching_players) > 1: matching_player_names = [ self.formatted_last_used_name(steam_id) for steam_id in matching_players ] formatted_player_names = "`, `".join(matching_player_names) reply_embed.description = f"More than one player matching your player name found. " \ f"Players matching `{stripped_player_name}` are:\n" \ f"`{formatted_player_names}`" await interaction.edit_original_message(embed=reply_embed) return matching_steam_id = int(matching_players[0]) db_return_value = self.db.srem( DISCORD_PLAYER_SUBSCRIPTION_KEY.format(interaction.user.id), stripped_player_name) last_used_name = self.formatted_last_used_name(matching_steam_id) if not db_return_value: immediate_reply_message = f"You were not subscribed to player `{last_used_name}`. " else: immediate_reply_message = f"You have been unsubscribed from player `{last_used_name}`. " reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_players = self.subscribed_players_of(interaction.user.id) formatted_players = "`, `".join([ self.formatted_last_used_name(subscribed_steam_id) for subscribed_steam_id in subscribed_players ]) if len(subscribed_players) == 0: reply_embed.description = f"{immediate_reply_message}\nYou are no longer subscribed to any players." await interaction.edit_original_message(embed=reply_embed) return reply_embed.description = f"{immediate_reply_message}\n" \ f"You are currently subscribed to the following players: `{formatted_players}`" await interaction.edit_original_message(embed=reply_embed) @unsubscribe_player.autocomplete("player") async def unsubscribe_player_autocomplete(self, interaction: Interaction, current: str) \ -> list[app_commands.Choice[str]]: subscribed_players = self.subscribed_players_of(interaction.user.id) candidates = [ steam_id for steam_id in subscribed_players if current.lower() in self.formatted_last_used_name(steam_id).lower() ] candidates.sort() return [ app_commands.Choice(name=self.formatted_last_used_name(steam_id), value=str(steam_id)) for steam_id in candidates[:25] ] @unsubscribe_group.command( name="member", description="Stop getting notified about a discord user") @app_commands.describe(member="Discord user you want to unsubscribe from") @app_commands.guild_only() async def unsubscribe_member(self, interaction: Interaction, member: Member): reply_embed = Embed(color=Color.blurple()) await interaction.response.defer(thinking=True, ephemeral=True) db_return_value = self.db.srem( DISCORD_MEMBER_SUBSCRIPTION_KEY.format(interaction.user.id), member.id) if not db_return_value: immediate_reply_message = f"You were not subscribed to Quake Live activities of {member.mention}." else: immediate_reply_message = f"You have been unsubscribed from Quake Live activities of {member.mention}." reply_embed.description = immediate_reply_message await interaction.edit_original_message(embed=reply_embed) subscribed_users = self.subscribed_users_of(interaction.user.id) if len(subscribed_users) == 0: reply_embed.description = f"{immediate_reply_message}\n" \ f"You are no longer subscribed to Quake Live activities of anyone." await interaction.edit_original_message(embed=reply_embed) return formatted_users = ", ".join( [user.mention for user in subscribed_users]) reply_embed.description = f"{immediate_reply_message}\n" \ f"You are still subscribed to Quake Live activities of {formatted_users}" await interaction.edit_original_message(embed=reply_embed) async def notify_map_change(self, mapname: str) -> None: notifications = [] for key in self.db.keys(DISCORD_MAP_SUBSCRIPTION_KEY.format("*")): if self.db.sismember(key, mapname): prefix = DISCORD_MAP_SUBSCRIPTION_KEY.split("{", maxsplit=1)[0] suffix = DISCORD_MAP_SUBSCRIPTION_KEY.rsplit("}", maxsplit=1)[-1] discord_id = int(key.replace(prefix, "").replace(suffix, "")) subscribed_discord_user = self.bot.get_user(discord_id) if subscribed_discord_user is None: continue notifications.append( subscribed_discord_user.send( content= f"`{self.format_mapname(mapname)}`, one of your favourite maps has been loaded!" )) await asyncio.gather(*notifications) async def notify_player_connected(self, player: Player) -> None: notifications = [] for key in self.db.keys(DISCORD_PLAYER_SUBSCRIPTION_KEY.format("*")): if self.db.sismember(key, player.steam_id): prefix = DISCORD_PLAYER_SUBSCRIPTION_KEY.split("{", maxsplit=1)[0] suffix = DISCORD_PLAYER_SUBSCRIPTION_KEY.rsplit("}", maxsplit=1)[-1] discord_id = int(key.replace(prefix, "").replace(suffix, "")) subscribed_discord_user = self.bot.get_user(discord_id) if subscribed_discord_user is None: continue notifications.append( subscribed_discord_user.send( content= f"`{player.clean_name}`, one of your followed players, " f"just connected to the server!")) await asyncio.gather(*notifications) async def check_subscriptions(self): notification_actions = [] game = None try: game = minqlx.Game() except minqlx.NonexistentGameError: pass if game is not None and game.map != self.last_notified_map: self.last_notified_map = game.map notification_actions.append( self.notify_map_change(self.last_notified_map)) players = Plugin.players() new_players = [ player for player in players if player.steam_id not in self.notified_steam_ids ] for player in new_players: notification_actions.append(self.notify_player_connected(player)) self.notified_steam_ids = [player.steam_id for player in players] await asyncio.gather(*notification_actions) # noinspection PyMethodMayBeStatic def find_relevant_activity(self, member: Member) -> Optional[Activity]: for activity in member.activities: if activity.type != ActivityType.playing: continue if activity.name is None or "Quake Live" not in activity.name: continue return activity return None @GroupCog.listener() async def on_presence_update(self, before: Member, after: Member): relevant_activity = self.find_relevant_activity(before) if relevant_activity is not None: return relevant_activity = self.find_relevant_activity(after) if relevant_activity is None: return notifications = [] for key in self.db.keys(DISCORD_MEMBER_SUBSCRIPTION_KEY.format("*")): if self.db.sismember(key, str(after.id)): prefix = DISCORD_MEMBER_SUBSCRIPTION_KEY.split("{", maxsplit=1)[0] suffix = DISCORD_MEMBER_SUBSCRIPTION_KEY.rsplit("}", maxsplit=1)[-1] discord_id = int(key.replace(prefix, "").replace(suffix, "")) informed_user = self.bot.get_user(discord_id) if informed_user is None: continue notifications.append( informed_user.send( content= f"{after.display_name}, a discord user you are subscribed to, " f"just started playing Quake Live.")) await asyncio.gather(*notifications)