diff --git a/src/pluralkit/bot/commands/__init__.py b/src/pluralkit/bot/commands/__init__.py index e439b590..4064d607 100644 --- a/src/pluralkit/bot/commands/__init__.py +++ b/src/pluralkit/bot/commands/__init__.py @@ -6,6 +6,7 @@ from typing import Tuple, Optional, Union from pluralkit import db from pluralkit.bot import embeds, utils +from pluralkit.errors import PluralKitError from pluralkit.member import Member from pluralkit.system import System @@ -13,6 +14,7 @@ logger = logging.getLogger("pluralkit.bot.commands") def next_arg(arg_string: str) -> Tuple[str, Optional[str]]: + # A basic quoted-arg parser if arg_string.startswith("\""): end_quote = arg_string[1:].find("\"") + 1 if end_quote > 0: @@ -135,18 +137,27 @@ import pluralkit.bot.commands.system_commands async def run_command(ctx: CommandContext, func): + # lol nested try try: - result = await func(ctx) + try: + await func(ctx) + except PluralKitError as e: + raise CommandError(e.message, e.help_page) except CommandError as e: content, embed = e.format() await ctx.reply(content=content, embed=embed) + async def command_dispatch(client: discord.Client, message: discord.Message, conn) -> bool: prefix = "^(pk(;|!)|<@{}> )".format(client.user.id) commands = [ (r"system (new|register|create|init)", system_commands.new_system), (r"system set", system_commands.system_set), + (r"system (name|rename)", system_commands.system_name), + (r"system description", system_commands.system_description), + (r"system avatar", system_commands.system_avatar), + (r"system tag", system_commands.system_tag), (r"system link", system_commands.system_link), (r"system unlink", system_commands.system_unlink), (r"system fronter", system_commands.system_fronter), @@ -159,6 +170,12 @@ async def command_dispatch(client: discord.Client, message: discord.Message, con (r"member (new|create|add|register)", member_commands.new_member), (r"member set", member_commands.member_set), + (r"member (name|rename)", member_commands.member_name), + (r"member description", member_commands.member_description), + (r"member avatar", member_commands.member_avatar), + (r"member color", member_commands.member_color), + (r"member (pronouns|pronoun)", member_commands.member_pronouns), + (r"member (birthday|birthdate)", member_commands.member_birthdate), (r"member proxy", member_commands.member_proxy), (r"member (delete|remove|destroy|erase)", member_commands.member_delete), (r"member", member_commands.member_info), diff --git a/src/pluralkit/bot/commands/member_commands.py b/src/pluralkit/bot/commands/member_commands.py index 4cf0fa09..34e1266f 100644 --- a/src/pluralkit/bot/commands/member_commands.py +++ b/src/pluralkit/bot/commands/member_commands.py @@ -1,5 +1,3 @@ -from datetime import datetime - import pluralkit.bot.embeds from pluralkit.bot import help from pluralkit.bot.commands import * @@ -27,73 +25,72 @@ async def new_member(ctx: CommandContext): raise CommandError(e.message) await ctx.reply_ok( - "Member \"{}\" (`{}`) registered! To register their proxy tags, use `pk;member proxy`.".format(new_name, member.hid)) + "Member \"{}\" (`{}`) registered! To register their proxy tags, use `pk;member proxy`.".format(new_name, + member.hid)) async def member_set(ctx: CommandContext): + raise CommandError( + "`pk;member set` has been retired. Please use the new member modifying commands: `pk;member [name|description|avatar|color|pronouns|birthdate]`.") + + +async def member_name(ctx: CommandContext): system = await ctx.ensure_system() member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_name = ctx.pop_str(CommandError("You must pass a new member name.", help=help.edit_member)) - property_name = ctx.pop_str(CommandError("You must pass a property name to set.", help=help.edit_member)) + await member.set_name(ctx.conn, system, new_name) + await ctx.reply_ok("Member name updated.") - async def name_setter(conn, new_name): - if not new_name: - raise CommandError("You can't clear the member name.") - await member.set_name(conn, system, new_name) - async def avatar_setter(conn, url): - if url: - user = await utils.parse_mention(ctx.client, url) - if user: - # Set the avatar to the mentioned user's avatar - # Discord pushes webp by default, which isn't supported by webhooks, but also hosts png alternatives - url = user.avatar_url.replace(".webp", ".png") +async def member_description(ctx: CommandContext): + await ctx.ensure_system() + member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_description = ctx.remaining() or None - await member.set_avatar(conn, url) + await member.set_description(ctx.conn, new_description) + await ctx.reply_ok("Member description {}.".format("updated" if new_description else "cleared")) - async def birthdate_setter(conn, date_str): - if date_str: - try: - date = datetime.strptime(date_str, "%Y-%m-%d").date() - except ValueError: - try: - # Try again, adding 0001 as a placeholder year - # This is considered a "null year" and will be omitted from the info card - # Useful if you want your birthday to be displayed yearless. - date = datetime.strptime("0001-" + date_str, "%Y-%m-%d").date() - except ValueError: - raise CommandError("Invalid date. Date must be in ISO-8601 format (YYYY-MM-DD, eg. 1999-07-25).") - else: - date = None - await member.set_birthdate(conn, date) +async def member_avatar(ctx: CommandContext): + await ctx.ensure_system() + member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_avatar_url = ctx.remaining() or None - properties = { - "name": name_setter, - "description": member.set_description, - "avatar": avatar_setter, - "color": member.set_color, - "pronouns": member.set_pronouns, - "birthdate": birthdate_setter, - } + if new_avatar_url: + user = await utils.parse_mention(ctx.client, new_avatar_url) + if user: + new_avatar_url = user.avatar_url_as(format="png") - if property_name not in properties: - raise CommandError( - "Unknown property {}. Allowed properties are {}.".format(property_name, ", ".join(properties.keys())), - help=help.edit_system) + await member.set_avatar(ctx.conn, new_avatar_url) + await ctx.reply_ok("Member avatar {}.".format("updated" if new_avatar_url else "cleared")) - value = ctx.remaining() or None - try: - await properties[property_name](ctx.conn, value) - except PluralKitError as e: - raise CommandError(e.message) +async def member_color(ctx: CommandContext): + await ctx.ensure_system() + member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_color = ctx.remaining() or None - # if prop == "avatar" and value: - # response.set_image(url=value) - # if prop == "color" and value: - # response.colour = int(value, 16) - await ctx.reply_ok("{} member {}.".format("Updated" if value else "Cleared", property_name)) + await member.set_color(ctx.conn, new_color) + await ctx.reply_ok("Member color {}.".format("updated" if new_color else "cleared")) + + +async def member_pronouns(ctx: CommandContext): + await ctx.ensure_system() + member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_pronouns = ctx.remaining() or None + + await member.set_pronouns(ctx.conn, new_pronouns) + await ctx.reply_ok("Member pronouns {}.".format("updated" if new_pronouns else "cleared")) + + +async def member_birthdate(ctx: CommandContext): + await ctx.ensure_system() + member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.edit_member)) + new_birthdate = ctx.remaining() or None + + await member.set_birthdate(ctx.conn, new_birthdate) + await ctx.reply_ok("Member birthdate {}.".format("updated" if new_birthdate else "cleared")) async def member_proxy(ctx: CommandContext): @@ -110,7 +107,7 @@ async def member_proxy(ctx: CommandContext): if example.count("text") != 1: raise CommandError("Example proxy message must contain the string 'text' exactly once.", - help=help.member_proxy) + help=help.member_proxy) # Extract prefix and suffix prefix = example[:example.index("text")].strip() @@ -132,7 +129,8 @@ async def member_delete(ctx: CommandContext): await ctx.ensure_system() member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.remove_member)) - delete_confirm_msg = "Are you sure you want to delete {}? If so, reply to this message with the member's ID (`{}`).".format(member.name, member.hid) + delete_confirm_msg = "Are you sure you want to delete {}? If so, reply to this message with the member's ID (`{}`).".format( + member.name, member.hid) if not await ctx.confirm_text(ctx.message.author, ctx.message.channel, member.hid, delete_confirm_msg): raise CommandError("Member deletion cancelled.") diff --git a/src/pluralkit/bot/commands/system_commands.py b/src/pluralkit/bot/commands/system_commands.py index 04e7f6d6..29b172b7 100644 --- a/src/pluralkit/bot/commands/system_commands.py +++ b/src/pluralkit/bot/commands/system_commands.py @@ -32,48 +32,53 @@ async def new_system(ctx: CommandContext): async def system_set(ctx: CommandContext): + raise CommandError("`pk;system set` has been retired. Please use the new member modifying commands: `pk;system [name|description|avatar|tag]`.") + + +async def system_name(ctx: CommandContext): system = await ctx.ensure_system() + new_name = ctx.remaining() or None - property_name = ctx.pop_str(CommandError("You must pass a property name to set.", help=help.edit_system)) + await system.set_name(ctx.conn, new_name) + await ctx.reply_ok("System name {}.".format("updated" if new_name else "cleared")) - async def avatar_setter(conn, url): - if url: - user = await utils.parse_mention(ctx.client, url) - if user: - # Set the avatar to the mentioned user's avatar - # Discord pushes webp by default, which isn't supported by webhooks, but also hosts png alternatives - url = user.avatar_url.replace(".webp", ".png") - await system.set_avatar(conn, url) +async def system_description(ctx: CommandContext): + system = await ctx.ensure_system() + new_description = ctx.remaining() or None - properties = { - "name": system.set_name, - "description": system.set_description, - "tag": system.set_tag, - "avatar": avatar_setter - } + await system.set_description(ctx.conn, new_description) + await ctx.reply_ok("System description {}.".format("updated" if new_description else "cleared")) - if property_name not in properties: - raise CommandError( - "Unknown property {}. Allowed properties are {}.".format(property_name, ", ".join(properties.keys())), - help=help.edit_system) - value = ctx.remaining() or None +async def system_tag(ctx: CommandContext): + system = await ctx.ensure_system() + new_tag = ctx.remaining() or None - try: - await properties[property_name](ctx.conn, value) - except PluralKitError as e: - raise CommandError(e.message) + await system.set_tag(ctx.conn, new_tag) + await ctx.reply_ok("System tag {}.".format("updated" if new_tag else "cleared")) - await ctx.reply_ok("{} system {}.".format("Updated" if value else "Cleared", property_name)) - # if prop == "avatar" and value: - # response.set_image(url=value) + +async def system_avatar(ctx: CommandContext): + system = await ctx.ensure_system() + new_avatar_url = ctx.remaining() or None + + if new_avatar_url: + user = await utils.parse_mention(ctx.client, new_avatar_url) + if user: + new_avatar_url = user.avatar_url_as(format="png") + + await system.set_avatar(ctx.conn, new_avatar_url) + await ctx.reply_ok("System avatar {}.".format("updated" if new_avatar_url else "cleared")) async def system_link(ctx: CommandContext): system = await ctx.ensure_system() account_name = ctx.pop_str(CommandError("You must pass an account to link this system to.", help=help.link_account)) + # Do the sanity checking here too (despite it being done in System.link_account) + # Because we want it to be done before the confirmation dialog is shown + # Find account to link linkee = await utils.parse_mention(ctx.client, account_name) if not linkee: diff --git a/src/pluralkit/bot/embeds.py b/src/pluralkit/bot/embeds.py index 2b3d0811..1b7d16f3 100644 --- a/src/pluralkit/bot/embeds.py +++ b/src/pluralkit/bot/embeds.py @@ -11,29 +11,41 @@ from pluralkit.switch import Switch from pluralkit.system import System from pluralkit.utils import get_fronters +def truncate_field_name(s: str) -> str: + return s[:256] + +def truncate_field_body(s: str) -> str: + return s[:1024] + +def truncate_description(s: str) -> str: + return s[:2048] + +def truncate_title(s: str) -> str: + return s[:256] + def success(text: str) -> discord.Embed: embed = discord.Embed() - embed.description = text + embed.description = truncate_description(text) embed.colour = discord.Colour.green() return embed def error(text: str, help: Tuple[str, str] = None) -> discord.Embed: embed = discord.Embed() - embed.description = text + embed.description = truncate_description(s) embed.colour = discord.Colour.dark_red() if help: help_title, help_text = help - embed.add_field(name=help_title, value=help_text) + embed.add_field(name=truncate_field_name(help_title), value=truncate_field_body(help_text)) return embed def status(text: str) -> discord.Embed: embed = discord.Embed() - embed.description = text + embed.description = truncate_description(text) embed.colour = discord.Colour.blue() return embed @@ -41,7 +53,7 @@ def status(text: str) -> discord.Embed: def exception_log(message_content, author_name, author_discriminator, author_id, server_id, channel_id) -> discord.Embed: embed = discord.Embed() embed.colour = discord.Colour.dark_red() - embed.title = message_content + embed.title = truncate_title(message_content) embed.set_footer(text="Sender: {}#{} ({}) | Server: {} | Channel: {}".format( author_name, author_discriminator, author_id, @@ -56,30 +68,30 @@ async def system_card(conn, client: discord.Client, system: System) -> discord.E card.colour = discord.Colour.blue() if system.name: - card.title = system.name + card.title = truncate_title(system.name) if system.avatar_url: card.set_thumbnail(url=system.avatar_url) if system.tag: - card.add_field(name="Tag", value=system.tag) + card.add_field(name="Tag", value=truncate_field_body(system.tag)) fronters, switch_time = await get_fronters(conn, system.id) if fronters: names = ", ".join([member.name for member in fronters]) fronter_val = "{} (for {})".format(names, humanize.naturaldelta(switch_time)) - card.add_field(name="Current fronter" if len(fronters) == 1 else "Current fronters", value=fronter_val) + card.add_field(name="Current fronter" if len(fronters) == 1 else "Current fronters", value=truncate_field_body(fronter_val)) account_names = [] for account_id in await system.get_linked_account_ids(conn): account = await client.get_user_info(account_id) account_names.append("{}#{}".format(account.name, account.discriminator)) - card.add_field(name="Linked accounts", value="\n".join(account_names)) + card.add_field(name="Linked accounts", value=truncate_field_body("\n".join(account_names))) if system.description: card.add_field(name="Description", - value=system.description, inline=False) + value=truncate_field_body(system.description), inline=False) # Get names of all members all_members = await system.get_members(conn) @@ -106,7 +118,7 @@ async def system_card(conn, client: discord.Client, system: System) -> discord.E field_name = "Members" if index >= 1: field_name = "Members (part {})".format(index + 1) - card.add_field(name=field_name, value=page, inline=False) + card.add_field(name=truncate_field_name(field_name), value=truncate_field_body(page), inline=False) card.set_footer(text="System ID: {}".format(system.hid)) return card @@ -122,7 +134,7 @@ async def member_card(conn, member: Member) -> discord.Embed: if system.name: name_and_system += " ({})".format(system.name) - card.set_author(name=name_and_system, icon_url=member.avatar_url or discord.Embed.Empty) + card.set_author(name=truncate_field_name(name_and_system), icon_url=member.avatar_url or discord.Embed.Empty) if member.avatar_url: card.set_thumbnail(url=member.avatar_url) @@ -136,7 +148,7 @@ async def member_card(conn, member: Member) -> discord.Embed: card.add_field(name="Birthdate", value=bday_val) if member.pronouns: - card.add_field(name="Pronouns", value=member.pronouns) + card.add_field(name="Pronouns", value=truncate_field_body(member.pronouns)) message_count = await member.message_count(conn) if message_count > 0: @@ -146,11 +158,11 @@ async def member_card(conn, member: Member) -> discord.Embed: prefix = member.prefix or "" suffix = member.suffix or "" card.add_field(name="Proxy Tags", - value="{}text{}".format(prefix, suffix)) + value=truncate_field_body("{}text{}".format(prefix, suffix))) if member.description: card.add_field(name="Description", - value=member.description, inline=False) + value=truncate_field_body(member.description), inline=False) card.set_footer(text="System ID: {} | Member ID: {}".format(system.hid, member.hid)) return card @@ -164,9 +176,9 @@ async def front_status(switch: Switch, conn) -> discord.Embed: if len(fronter_names) == 0: embed.add_field(name="Current fronter", value="(no fronter)") elif len(fronter_names) == 1: - embed.add_field(name="Current fronter", value=fronter_names[0]) + embed.add_field(name="Current fronter", value=truncate_field_body(fronter_names[0])) else: - embed.add_field(name="Current fronters", value=", ".join(fronter_names)) + embed.add_field(name="Current fronters", value=truncate_field_body(", ".join(fronter_names))) if switch.timestamp: embed.add_field(name="Since", diff --git a/src/pluralkit/bot/help.py b/src/pluralkit/bot/help.py index 427c3028..ac379fd6 100644 --- a/src/pluralkit/bot/help.py +++ b/src/pluralkit/bot/help.py @@ -20,13 +20,13 @@ For example: `pk;system` - Shows details of your own system. `pk;system abcde` - Shows details of the system with the ID `abcde`. `pk;system @JohnsAccount` - Shows details of the system linked to @JohnsAccount.""") -edit_system = ("Editing system properties", """You can use the `pk;system set` command to change your system properties. The properties you can change are name, description, and tag. +edit_system = ("Editing system properties", """You can use the `pk;system` commands to change your system properties. The properties you can change are name, description, and tag. For example: -`pk;system set name My System` - sets your system name to "My System". -`pk;system set description A really cool system.` - sets your system description. -`pk;system set tag [MS]` - Sets the tag (which will be displayed after member names in messages) to "[MS]". -`pk;system set avatar https://placekitten.com/400/400` - Changes your system's avatar to a linked image. +`pk;system name My System` - sets your system name to "My System". +`pk;system description A really cool system.` - sets your system description. +`pk;system tag [MS]` - Sets the tag (which will be displayed after member names in messages) to "[MS]". +`pk;system avatar https://placekitten.com/400/400` - Changes your system's avatar to a linked image. If you don't specify any value, the property will be cleared.""") link_account = ("Linking accounts", """If your system has multiple accounts, you can link all of them to your system, and you can use the bot from all of those accounts. @@ -48,17 +48,17 @@ For example: `pk;member abcde` - Shows details of the member with the ID `abcde`. You can use member IDs to look up members in other systems.""") -edit_member = ("Editing member properties", """You can use the `pk;member set` command to change a member's properties. The properties you can change are name, description, color, pronouns, birthdate and avatar. +edit_member = ("Editing member properties", """You can use the `pk;member` commands to change a member's properties. The properties you can change are name, description, color, pronouns, birthdate and avatar. For example: -`pk;member set John name Joe` - Changes John's name to Joe. -`pk;member set John description Pretty cool dude.` - Changes John's description. -`pk;member set John color #ff0000` - Changes John's color to red. -`pk;member set John pronouns he/him` - Changes John's pronouns. -`pk;member set John birthdate 1996-02-27` - Changes John's birthdate to Feb 27, 1996. (Must be YYYY-MM-DD format). -`pk;member set John birthdate 02-27` - Changes John's birthdate to February 27th, with no year. -`pk;member set John avatar https://placekitten.com/400/400` - Changes John's avatar to a linked image. -`pk;member set John avatar @JohnsAccount` - Changes John's avatar to the avatar of the mentioned account. +`pk;member name John Joe` - Changes John's name to Joe. +`pk;member description John Pretty cool dude.` - Changes John's description. +`pk;member color John #ff0000` - Changes John's color to red. +`pk;member pronouns John he/him` - Changes John's pronouns. +`pk;member birthdate John 1996-02-27` - Changes John's birthdate to Feb 27, 1996. (Must be YYYY-MM-DD format). +`pk;member birthdate John 02-27` - Changes John's birthdate to February 27th, with no year. +`pk;member avatar John https://placekitten.com/400/400` - Changes John's avatar to a linked image. +`pk;member avatar John @JohnsAccount` - Changes John's avatar to the avatar of the mentioned account. If you don't specify any value, the property will be cleared.""") remove_member = ("Removing a member", """If you want to delete a member, you can use the `pk;member delete` command. diff --git a/src/pluralkit/errors.py b/src/pluralkit/errors.py index 952d799d..b9506d75 100644 --- a/src/pluralkit/errors.py +++ b/src/pluralkit/errors.py @@ -67,4 +67,8 @@ class MemberNameTooLongError(PluralKitError): class InvalidColorError(PluralKitError): def __init__(self): - super().__init__("Color must be a valid hex color. (eg. #ff0000)") \ No newline at end of file + super().__init__("Color must be a valid hex color. (eg. #ff0000)") + +class InvalidDateStringError(PluralKitError): + def __init__(self): + super().__init__("Invalid date string. Date must be in ISO-8601 format (YYYY-MM-DD, eg. 1999-07-25).") \ No newline at end of file diff --git a/src/pluralkit/member.py b/src/pluralkit/member.py index a02f4c5b..7b97288d 100644 --- a/src/pluralkit/member.py +++ b/src/pluralkit/member.py @@ -2,7 +2,7 @@ import re from datetime import date, datetime from collections.__init__ import namedtuple -from typing import Optional +from typing import Optional, Union from pluralkit import db, errors from pluralkit.utils import validate_avatar_url_or_raise, contains_custom_emoji @@ -59,9 +59,16 @@ class Member(namedtuple("Member", Set the name of a member. Requires the system to be passed in order to bounds check with the system tag. :raises: MemberNameTooLongError, CustomEmojiError """ + # Custom emojis can't go in the member name + # Technically they *could* but they wouldn't render properly + # so I'd rather explicitly ban them to in order to avoid confusion + + # The textual form is longer than the length limit in most cases + # so we check this *before* the length check for better errors if contains_custom_emoji(new_name): raise errors.CustomEmojiError() + # Explicit name length checking if len(new_name) > system.get_member_name_limit(): raise errors.MemberNameTooLongError(tag_present=bool(system.tag)) @@ -72,6 +79,7 @@ class Member(namedtuple("Member", Set or clear the description of a member. :raises: DescriptionTooLongError """ + # Explicit length checking if new_description and len(new_description) > 1024: raise errors.DescriptionTooLongError() @@ -96,14 +104,31 @@ class Member(namedtuple("Member", if new_color: match = re.fullmatch("#?([0-9A-Fa-f]{6})", new_color) if not match: - return errors.InvalidColorError() + raise errors.InvalidColorError() cleaned_color = match.group(1).lower() await db.update_member_field(conn, self.id, "color", cleaned_color) - async def set_birthdate(self, conn, new_date: date): - """Set or clear the birthdate of a member. To hide the birth year, pass a year of 0001.""" + async def set_birthdate(self, conn, new_date: Union[date, str]): + """ + Set or clear the birthdate of a member. To hide the birth year, pass a year of 0001. + :raises: InvalidDateStringError + """ + + if isinstance(new_date, str): + date_str = new_date + try: + new_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + try: + # Try again, adding 0001 as a placeholder year + # This is considered a "null year" and will be omitted from the info card + # Useful if you want your birthday to be displayed yearless. + new_date = datetime.strptime("0001-" + date_str, "%Y-%m-%d").date() + except ValueError: + raise errors.InvalidDateStringError() + await db.update_member_field(conn, self.id, "birthday", new_date) async def set_pronouns(self, conn, new_pronouns: str): diff --git a/src/pluralkit/system.py b/src/pluralkit/system.py index ba98f082..7816dfd9 100644 --- a/src/pluralkit/system.py +++ b/src/pluralkit/system.py @@ -48,6 +48,7 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a await db.update_system_field(conn, self.id, "name", new_name) async def set_description(self, conn, new_description: Optional[str]): + # Explicit length error if new_description and len(new_description) > 1024: raise errors.DescriptionTooLongError() @@ -55,12 +56,14 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a async def set_tag(self, conn, new_tag: Optional[str]): if new_tag: + # Explicit length error if len(new_tag) > 32: raise errors.TagTooLongError() if contains_custom_emoji(new_tag): raise errors.CustomEmojiError() + # Check name+tag length for all members members_exceeding = await db.get_members_exceeding(conn, system_id=self.id, length=32 - len(new_tag) - 1) if len(members_exceeding) > 0: raise errors.TagTooLongWithMembersError([member.name for member in members_exceeding])