diff options
author | 2023-12-13 15:37:33 +0000 | |
---|---|---|
committer | 2024-05-23 21:50:13 +0100 | |
commit | 6bd8cd14493a7357bb4417b4812fa23bc32538af (patch) | |
tree | a1642428003a68558fc66114ff968d5cf966f063 | |
parent | --- (#3066) (diff) |
Add a cog to manage tracking a user's alt accounts
-rw-r--r-- | bot/exts/moderation/alts.py | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/bot/exts/moderation/alts.py b/bot/exts/moderation/alts.py new file mode 100644 index 000000000..b383fecb4 --- /dev/null +++ b/bot/exts/moderation/alts.py @@ -0,0 +1,169 @@ +import gettext + +import discord +from discord.ext import commands +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.members import get_or_fetch_member + +from bot import constants +from bot.bot import Bot +from bot.converters import UnambiguousMemberOrUser +from bot.log import get_logger +from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel +from bot.utils.time import discord_timestamp + +log = get_logger(__name__) + + +class AlternateAccounts(commands.Cog): + """A cog used to track a user's alternative accounts across Discord.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def error_text_from_error(error: ResponseCodeError) -> str: + """Format the error into a user-facing message.""" + if resp_json := error.response_json: + errors = ", ".join( + resp_json.get("non_field_errors", []) + + resp_json.get("source", []) + + resp_json.get("detail", []) + ) + if errors: + return gettext.ngettext("Error from site: ", "Errors from site: ", len(errors)) + errors + return str(error.response_json) + return error.response_text + + async def alts_to_string(self, alts: list[dict[str: str | int | list[int]]]) -> list[str]: + """Convert a list of alts to a list of string representations.""" + lines = [] + guild = self.bot.get_guild(self.bot.guild_id) + for idx, alt in enumerate(alts): + alt_obj = await get_or_fetch_member(guild, alt["target"]) + alt_name = str(alt_obj) if alt_obj else alt["target"] + created_at = discord_timestamp(alt["created_at"]) + updated_at = discord_timestamp(alt["updated_at"]) + + edited = f" edited on {updated_at}\n" if "edited" in alt else "\n" + num_alts = len(alt["alts"]) + lines.append( + f"**Association #{idx} - {alt_name}**\n" + f"<@{alt['target']}> - {alt['target']}\n" + f"Issued by: <@{alt['actor']}> ({alt['actor']}) on {created_at}{edited}" + f"Context: {alt['context']}\n" + f"<@{alt['target']}> has {num_alts} associated {gettext.ngettext('account', 'accounts', num_alts)}" + ) + return lines + + @commands.group(name="association ", aliases=("alt", "alts", "assoc"), invoke_without_command=True) + async def association_group( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + *, + context: str, + ) -> None: + """ + Alternate accounts commands. + + When called directly marks the two users given as alt accounts. + The context as to why they are believed to be alt accounts must be given. + """ + try: + await self.bot.api_client.post( + f"bot/users/{user_1.id}/alts", + json={"target": user_2.id, "actor": ctx.author.id, "context": context}, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ {user_1.mention} and {user_2.mention} successfully marked as alts.") + + @association_group.command(name="edit ", aliases=("e",)) + async def edit_association_command( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + *, + context: str, + ) -> None: + """Edit the context of an association between two users.""" + try: + await self.bot.api_client.patch( + f"bot/users/{user_1.id}/alts", + json={"target": user_2.id, "context": context}, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ Context for association between {user_1.mention} and {user_2.mention} updated.") + + @association_group.command(name="remove", aliases=("r",)) + async def alt_remove_command( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + ) -> None: + """Remove the alt association between the two users.""" + try: + await self.bot.api_client.delete( + f"bot/users/{user_1.id}/alts", + json=user_2.id, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ {user_1.mention} and {user_2.mention} are no longer marked as alts.") + + @association_group.command(name="info", root_aliases=("alt-info",)) + async def alt_info_command( + self, + ctx: commands.Context, + user: UnambiguousMemberOrUser, + ) -> None: + """Output a list of known alts of this user, and the reasons as to why they are believed to be alts.""" + try: + resp = await self.bot.api_client.get(f"bot/users/{user.id}") + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: {user.mention} not found in site database") + return + raise + alts = resp["alts"] + if not alts: + await ctx.send(f":x: No known alts for {user}") + return + + embed = discord.Embed( + title=f"Associated accounts for {user} ({len(alts)} total)", + colour=discord.Colour.orange(), + ) + lines = await self.alts_to_string(alts) + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000, + ) + + async def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators inside moderator channels to invoke the commands in this cog.""" + checks = [ + await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), + is_mod_channel(ctx.channel) + ] + return all(checks) + +async def setup(bot: Bot) -> None: + """Load the AlternateAccounts cog.""" + await bot.add_cog(AlternateAccounts(bot)) |