Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a cog to manage tracking a user's alt accounts #2845

Merged
merged 8 commits into from
May 23, 2024
7 changes: 7 additions & 0 deletions bot/exts/info/information.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_me
if is_mod_channel(ctx.channel):
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
fields.append(await self.user_alt_count(user))
else:
fields.append(await self.basic_user_infraction_counts(user))

Expand All @@ -340,6 +341,12 @@ async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_me

return embed

async def user_alt_count(self, user: MemberOrUser) -> tuple[str, int | str]:
"""Get the number of alts for the given member."""
resp = await self.bot.api_client.get(f"bot/users/{user.id}")
return ("Associated accounts", len(resp["alts"]) or "No associated accounts")


async def basic_user_infraction_counts(self, user: MemberOrUser) -> tuple[str, str]:
"""Gets the total and active infraction counts for the given `member`."""
infractions = await self.bot.api_client.get(
Expand Down
174 changes: 174 additions & 0 deletions bot/exts/moderation/alts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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:
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
"""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("target", []) +
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]) -> 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):
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
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,
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
*,
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.
"""
if user_1.bot or user_2.bot:
await ctx.send(":x: Cannot mark bots as alts")
return

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,
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
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."""
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
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))
5 changes: 5 additions & 0 deletions bot/exts/moderation/infraction/management.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import gettext
import re
import textwrap
import typing as t
Expand Down Expand Up @@ -313,6 +314,10 @@ async def search_user(self, ctx: Context, user: MemberOrUser | discord.Object) -
)
# Manually form mention from ID as discord.Object doesn't have a `.mention` attr
prefix = f"<@{user.id}> - {user.id}"
# If the user has alts show in the prefix
shtlrs marked this conversation as resolved.
Show resolved Hide resolved
if infraction_list and (alts := infraction_list[0]["user"]["alts"]):
prefix += f" ({len(alts)} associated {gettext.ngettext('account', 'accounts', len(alts))})"

await self.send_infraction_list(ctx, embed, infraction_list, prefix, ("user",))

@infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
Expand Down
Loading