diff --git a/src/pluralkit/bot/__init__.py b/src/pluralkit/bot/__init__.py index a56e130c..e85122ac 100644 --- a/src/pluralkit/bot/__init__.py +++ b/src/pluralkit/bot/__init__.py @@ -1,12 +1,10 @@ -import asyncpg +import asyncio import sys -import asyncio -import os - -import logging - +import asyncpg import discord +import logging +import os import traceback from pluralkit import db @@ -136,4 +134,4 @@ def run(): file=sys.stderr) sys.exit(1) - client.run(bot_token) \ No newline at end of file + client.run(bot_token) diff --git a/src/pluralkit/bot/channel_logger.py b/src/pluralkit/bot/channel_logger.py index acbb2415..56aea9a1 100644 --- a/src/pluralkit/bot/channel_logger.py +++ b/src/pluralkit/bot/channel_logger.py @@ -1,8 +1,7 @@ +import discord import logging from datetime import datetime -import discord - from pluralkit import db @@ -71,8 +70,8 @@ class ChannelLogger: embed_set_author_name(embed, channel_name, member_name, system_name, member_avatar_url) embed.set_footer( text="System ID: {} | Member ID: {} | Sender: {}#{} ({}) | Message ID: {}".format(system_hid, member_hid, - sender_name, sender_disc, - sender_id, message_id)) + sender_name, sender_disc, + sender_id, message_id)) if message_image: embed.set_thumbnail(url=message_image) @@ -99,6 +98,7 @@ class ChannelLogger: embed.timestamp = datetime.utcnow() embed_set_author_name(embed, channel_name, member_name, system_name, member_avatar_url) - embed.set_footer(text="System ID: {} | Member ID: {} | Message ID: {}".format(system_hid, member_hid, message_id)) + embed.set_footer( + text="System ID: {} | Member ID: {} | Message ID: {}".format(system_hid, member_hid, message_id)) await self.send_to_log_channel(log_channel, embed) diff --git a/src/pluralkit/bot/commands/__init__.py b/src/pluralkit/bot/commands/__init__.py index 3903c501..96972597 100644 --- a/src/pluralkit/bot/commands/__init__.py +++ b/src/pluralkit/bot/commands/__init__.py @@ -1,6 +1,6 @@ import asyncio + import discord -import logging import re from typing import Tuple, Optional, Union @@ -10,8 +10,6 @@ from pluralkit.errors import PluralKitError from pluralkit.member import Member from pluralkit.system import System -logger = logging.getLogger("pluralkit.bot.commands") - def next_arg(arg_string: str) -> Tuple[str, Optional[str]]: # A basic quoted-arg parser @@ -68,6 +66,19 @@ class CommandContext: popped, self.args = next_arg(self.args) return popped + def peek_str(self) -> Optional[str]: + if not self.args: + return None + popped, _ = next_arg(self.args) + return popped + + def match(self, next) -> bool: + peeked = self.peek_str() + if peeked and peeked.lower() == next.lower(): + self.pop_str() + return True + return False + async def pop_system(self, error: CommandError = None) -> System: name = self.pop_str(error) system = await utils.get_system_fuzzy(self.conn, self.client, name) @@ -103,6 +114,13 @@ class CommandContext: async def reply_warn(self, content=None, embed=None): return await self.reply(content="\u26a0 {}".format(content or ""), embed=embed) + async def reply_ok_dm(self, content: str): + if isinstance(self.message.channel, discord.DMChannel): + await self.reply_ok(content="\u2705 {}".format(content or "")) + else: + await self.message.author.send(content="\u2705 {}".format(content or "")) + await self.reply_ok("DM'd!") + async def confirm_react(self, user: Union[discord.Member, discord.User], message: discord.Message): await message.add_reaction("\u2705") # Checkmark await message.add_reaction("\u274c") # Red X @@ -138,6 +156,35 @@ import pluralkit.bot.commands.switch_commands import pluralkit.bot.commands.system_commands +async def command_root(ctx: CommandContext): + if ctx.match("system"): + await system_commands.system_root(ctx) + elif ctx.match("member"): + await member_commands.member_root(ctx) + elif ctx.match("link"): + await system_commands.account_link(ctx) + elif ctx.match("unlink"): + await system_commands.account_unlink(ctx) + elif ctx.match("message"): + await message_commands.message_info(ctx) + elif ctx.match("log"): + await mod_commands.set_log(ctx) + elif ctx.match("invite"): + await misc_commands.invite_link(ctx) + elif ctx.match("export"): + await misc_commands.export(ctx) + elif ctx.match("switch"): + await switch_commands.switch_root(ctx) + elif ctx.match("token"): + await api_commands.token_root(ctx) + elif ctx.match("import"): + await import_commands.import_root(ctx) + elif ctx.match("help"): + await misc_commands.help_root(ctx) + else: + raise CommandError("Unknown command {}. For a list of commands, type `pk;help commands`.".format(ctx.pop_str())) + + async def run_command(ctx: CommandContext, func): # lol nested try try: @@ -150,71 +197,20 @@ async def run_command(ctx: CommandContext, func): 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 (delete|remove|destroy|erase)", system_commands.system_delete), - (r"system", system_commands.system_info), + regex = re.compile(prefix, re.IGNORECASE) - (r"front", system_commands.system_fronter), - (r"front history", system_commands.system_fronthistory), - (r"front percent(age)?", system_commands.system_frontpercent), - - (r"import tupperware", import_commands.import_tupperware), - - (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), - - (r"message", message_commands.message_info), - - (r"log", mod_commands.set_log), - - (r"invite", misc_commands.invite_link), - (r"export", misc_commands.export), - - (r"help", misc_commands.show_help), - - (r"switch move", switch_commands.switch_move), - (r"switch out", switch_commands.switch_out), - (r"switch", switch_commands.switch_member), - - (r"token (refresh|expire|update)", api_commands.refresh_token), - (r"token", api_commands.get_token) - ] - - for pattern, func in commands: - regex = re.compile(prefix + pattern, re.IGNORECASE) - - cmd = message.content - match = regex.match(cmd) - if match: - remaining_string = cmd[match.span()[1]:].strip() - - ctx = CommandContext( - client=client, - message=message, - conn=conn, - args=remaining_string - ) - - await run_command(ctx, func) - return True + cmd = message.content + match = regex.match(cmd) + if match: + remaining_string = cmd[match.span()[1]:].strip() + ctx = CommandContext( + client=client, + message=message, + conn=conn, + args=remaining_string + ) + await run_command(ctx, command_root) + return True return False diff --git a/src/pluralkit/bot/commands/api_commands.py b/src/pluralkit/bot/commands/api_commands.py index a3597635..2e658279 100644 --- a/src/pluralkit/bot/commands/api_commands.py +++ b/src/pluralkit/bot/commands/api_commands.py @@ -1,18 +1,16 @@ -import logging -from discord import DMChannel - from pluralkit.bot.commands import CommandContext -logger = logging.getLogger("pluralkit.commands") disclaimer = "Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`." -async def reply_dm(ctx: CommandContext, message: str): - await ctx.message.author.send(message) - if not isinstance(ctx.message.channel, DMChannel): - await ctx.reply_ok("DM'd!") +async def token_root(ctx: CommandContext): + if ctx.match("refresh") or ctx.match("expire") or ctx.match("invalidate") or ctx.match("update"): + await token_refresh(ctx) + else: + await token_get(ctx) -async def get_token(ctx: CommandContext): + +async def token_get(ctx: CommandContext): system = await ctx.ensure_system() if system.token: @@ -21,11 +19,13 @@ async def get_token(ctx: CommandContext): token = await system.refresh_token(ctx.conn) token_message = "Here's your API token: \n**`{}`**\n{}".format(token, disclaimer) - return await reply_dm(ctx, token_message) + return await ctx.reply_ok_dm(token_message) -async def refresh_token(ctx: CommandContext): + +async def token_refresh(ctx: CommandContext): system = await ctx.ensure_system() token = await system.refresh_token(ctx.conn) - token_message = "Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\nHere's your new API token:\n**`{}`**\n{}".format(token, disclaimer) - return await reply_dm(ctx, token_message) \ No newline at end of file + token_message = "Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\nHere's your new API token:\n**`{}`**\n{}".format( + token, disclaimer) + return await ctx.reply_ok_dm(token_message) diff --git a/src/pluralkit/bot/commands/import_commands.py b/src/pluralkit/bot/commands/import_commands.py index c714701b..4a93f2c1 100644 --- a/src/pluralkit/bot/commands/import_commands.py +++ b/src/pluralkit/bot/commands/import_commands.py @@ -1,16 +1,17 @@ -import asyncio from datetime import datetime -import pluralkit.utils from pluralkit.bot.commands import * -logger = logging.getLogger("pluralkit.commands") + +async def import_root(ctx: CommandContext): + # Only one import method rn, so why not default to Tupperware? + await import_tupperware(ctx) async def import_tupperware(ctx: CommandContext): - tupperware_member = ctx.message.guild.get_member(431544605209788416) - # Check if there's a Tupperware bot on the server + # Main instance of TW has that ID, at least + tupperware_member = ctx.message.guild.get_member(431544605209788416) if not tupperware_member: raise CommandError("This command only works in a server where the Tupperware bot is also present.") @@ -20,8 +21,7 @@ async def import_tupperware(ctx: CommandContext): # If it doesn't, throw error raise CommandError("This command only works in a channel where the Tupperware bot has read/send access.") - await ctx.reply( - embed=embeds.status("Please reply to this message with `tul!list` (or the server equivalent).")) + await ctx.reply("Please reply to this message with `tul!list` (or the server equivalent).") # Check to make sure the message is sent by Tupperware, and that the Tupperware response actually belongs to the correct user def ensure_account(tw_msg): @@ -73,8 +73,9 @@ async def import_tupperware(ctx: CommandContext): # If this isn't the same page as last check, edit the status message if new_page != current_page: last_found_time = datetime.utcnow() - await status_msg.edit(content="Multi-page member list found. Please manually scroll through all the pages. Read {}/{} pages.".format( - len(pages_found), total_pages)) + await status_msg.edit( + content="Multi-page member list found. Please manually scroll through all the pages. Read {}/{} pages.".format( + len(pages_found), total_pages)) current_page = new_page # And sleep a bit to prevent spamming the CPU @@ -91,15 +92,10 @@ async def import_tupperware(ctx: CommandContext): # Also edit the status message to indicate we're now importing, and it may take a while because there's probably a lot of members await status_msg.edit(content="All pages read. Now importing...") - logger.debug("Importing from Tupperware...") - # Create new (nameless) system if there isn't any registered system = await ctx.get_system() if system is None: - hid = pluralkit.utils.generate_hid() - logger.debug("Creating new system (hid={})...".format(hid)) - system = await db.create_system(ctx.conn, system_name=None, system_hid=hid) - await db.link_account(ctx.conn, system_id=system.id, account_id=ctx.message.author.id) + system = await System.create_system(ctx.conn, ctx.message.author.id) for embed in tupperware_page_embeds: for field in embed["fields"]: @@ -133,24 +129,16 @@ async def import_tupperware(ctx: CommandContext): member_description = line # Read by name - TW doesn't allow name collisions so we're safe here (prevents dupes) - existing_member = await db.get_member_by_name(ctx.conn, system_id=system.id, member_name=name) + existing_member = await Member.get_member_by_name(ctx.conn, system.id, name) if not existing_member: # Or create a new member - hid = pluralkit.utils.generate_hid() - logger.debug("Creating new member {} (hid={})...".format(name, hid)) - existing_member = await db.create_member(ctx.conn, system_id=system.id, member_name=name, - member_hid=hid) + existing_member = await system.create_member(ctx.conn, name) # Save the new stuff in the DB - logger.debug("Updating fields...") - await db.update_member_field(ctx.conn, member_id=existing_member.id, field="prefix", value=member_prefix) - await db.update_member_field(ctx.conn, member_id=existing_member.id, field="suffix", value=member_suffix) - await db.update_member_field(ctx.conn, member_id=existing_member.id, field="avatar_url", - value=member_avatar) - await db.update_member_field(ctx.conn, member_id=existing_member.id, field="birthday", - value=member_birthdate) - await db.update_member_field(ctx.conn, member_id=existing_member.id, field="description", - value=member_description) + await existing_member.set_proxy_tags(ctx.conn, member_prefix, member_suffix) + await existing_member.set_avatar(ctx.conn, member_avatar) + await existing_member.set_birthdate(ctx.conn, member_birthdate) + await existing_member.set_description(ctx.conn, member_description) await ctx.reply_ok( "System information imported. Try using `pk;system` now.\nYou should probably remove your members from Tupperware to avoid double-posting.") diff --git a/src/pluralkit/bot/commands/member_commands.py b/src/pluralkit/bot/commands/member_commands.py index 206cac6b..823c2d9c 100644 --- a/src/pluralkit/bot/commands/member_commands.py +++ b/src/pluralkit/bot/commands/member_commands.py @@ -3,25 +3,74 @@ from pluralkit.bot import help from pluralkit.bot.commands import * from pluralkit.errors import PluralKitError -logger = logging.getLogger("pluralkit.commands") + +async def member_root(ctx: CommandContext): + if ctx.match("new") or ctx.match("create") or ctx.match("add") or ctx.match("register"): + await new_member(ctx) + elif ctx.match("help"): + await ctx.reply(help.member_commands) + elif ctx.match("set"): + await member_set(ctx) + # TODO "pk;member list" + + if not ctx.has_next(): + raise CommandError("Must pass a subcommand. For a list of subcommands, type `pk;member help`.") + + await specific_member_root(ctx) -async def member_info(ctx: CommandContext): - member = await ctx.pop_member( - error=CommandError("You must pass a member name or ID.", help=help.lookup_member), system_only=False) +async def specific_member_root(ctx: CommandContext): + member = await ctx.pop_member(system_only=False) + + if ctx.has_next(): + # Following commands operate on members only in the caller's own system + # error if not, to make sure you can't destructively edit someone else's member + system = await ctx.ensure_system() + if not member.system == system.id: + raise CommandError("Member must be in your own system.") + + if ctx.match("name") or ctx.match("rename"): + await member_name(ctx, member) + elif ctx.match("description"): + await member_description(ctx, member) + elif ctx.match("avatar") or ctx.match("icon"): + await member_avatar(ctx, member) + elif ctx.match("proxy") or ctx.match("tags"): + await member_proxy(ctx, member) + elif ctx.match("pronouns") or ctx.match("pronoun"): + await member_pronouns(ctx, member) + elif ctx.match("color") or ctx.match("colour"): + await member_color(ctx, member) + elif ctx.match("birthday") or ctx.match("birthdate"): + await member_birthdate(ctx, member) + elif ctx.match("delete") or ctx.match("remove") or ctx.match("destroy") or ctx.match("erase"): + await member_delete(ctx, member) + elif ctx.match("help"): + await ctx.reply(help.member_commands) + else: + raise CommandError( + "Unknown subcommand {}. For a list of all commands, type `pk;member help`".format(ctx.pop_str())) + else: + # Basic lookup + await member_info(ctx, member) + + +async def member_info(ctx: CommandContext, member: Member): await ctx.reply(embed=await pluralkit.bot.embeds.member_card(ctx.conn, member)) async def new_member(ctx: CommandContext): system = await ctx.ensure_system() if not ctx.has_next(): - raise CommandError("You must pass a name for the new member.", help=help.add_member) + raise CommandError("You must pass a name for the new member.") new_name = ctx.remaining() existing_member = await Member.get_member_by_name(ctx.conn, system.id, new_name) if existing_member: - msg = await ctx.reply_warn("There is already a member with this name, with the ID `{}`. Do you want to create a duplicate member anyway?".format(existing_member.hid)) + msg = await ctx.reply_warn( + "There is already a member with this name, with the ID `{}`. Do you want to create a duplicate member anyway?".format( + existing_member.hid)) if not await ctx.confirm_react(ctx.message.author, msg): raise CommandError("Member creation cancelled.") @@ -37,18 +86,19 @@ async def new_member(ctx: CommandContext): 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]`.") + "`pk;member set` has been retired. Please use the new member modifying commands. Type `pk;member help` for a list.") -async def member_name(ctx: CommandContext): +async def member_name(ctx: CommandContext, member: Member): 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)) + new_name = ctx.pop_str(CommandError("You must pass a new member name.")) # Warn if there's a member by the same name already existing_member = await Member.get_member_by_name(ctx.conn, system.id, new_name) - if existing_member: - msg = await ctx.reply_warn("There is already a member with this name, with the ID `{}`. Do you want to rename this member anyway? This will result in two members with the same name.".format(existing_member.hid)) + if existing_member and existing_member.id != member.id: + msg = await ctx.reply_warn( + "There is already another member with this name, with the ID `{}`. Do you want to rename this member anyway? This will result in two members with the same name.".format( + existing_member.hid)) if not await ctx.confirm_react(ctx.message.author, msg): raise CommandError("Member renaming cancelled.") @@ -56,27 +106,28 @@ async def member_name(ctx: CommandContext): await ctx.reply_ok("Member name updated.") if len(new_name) < 2 and not system.tag: - await ctx.reply_warn("This member's new name is under 2 characters, and thus cannot be proxied. To prevent this, use a longer member name, or add a system tag.") + await ctx.reply_warn( + "This member's new name is under 2 characters, and thus cannot be proxied. To prevent this, use a longer member name, or add a system tag.") elif len(new_name) > 32: exceeds_by = len(new_name) - 32 - await ctx.reply_warn("This member's new name is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the member name by {} characters.".format(exceeds_by)) + await ctx.reply_warn( + "This member's new name is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the member name by {} characters.".format( + exceeds_by)) elif len(new_name) > system.get_member_name_limit(): exceeds_by = len(new_name) - system.get_member_name_limit() - await ctx.reply_warn("This member's new name, when combined with the system tag `{}`, is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the name or system tag by at least {} characters.".format(system.tag, exceeds_by)) + await ctx.reply_warn( + "This member's new name, when combined with the system tag `{}`, is longer than 32 characters, and thus cannot be proxied. To prevent this, shorten the name or system tag by at least {} characters.".format( + system.tag, exceeds_by)) -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)) +async def member_description(ctx: CommandContext, member: Member): new_description = ctx.remaining() or None await member.set_description(ctx.conn, new_description) await ctx.reply_ok("Member description {}.".format("updated" if new_description else "cleared")) -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)) +async def member_avatar(ctx: CommandContext, member: Member): new_avatar_url = ctx.remaining() or None if new_avatar_url: @@ -88,53 +139,42 @@ async def member_avatar(ctx: CommandContext): await ctx.reply_ok("Member avatar {}.".format("updated" if new_avatar_url else "cleared")) -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)) +async def member_color(ctx: CommandContext, member: Member): new_color = ctx.remaining() or None 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)) +async def member_pronouns(ctx: CommandContext, member: 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)) +async def member_birthdate(ctx: CommandContext, member: 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): - await ctx.ensure_system() - member = await ctx.pop_member(CommandError("You must pass a member name.", help=help.member_proxy)) - +async def member_proxy(ctx: CommandContext, member: Member): if not ctx.has_next(): prefix, suffix = None, None else: # Sanity checking example = ctx.remaining() if "text" not in example: - raise CommandError("Example proxy message must contain the string 'text'.", help=help.member_proxy) + raise CommandError("Example proxy message must contain the string 'text'. For help, type `pk;help proxy`.") if example.count("text") != 1: - raise CommandError("Example proxy message must contain the string 'text' exactly once.", - help=help.member_proxy) + raise CommandError("Example proxy message must contain the string 'text' exactly once. For help, type `pk;help proxy`.") # Extract prefix and suffix prefix = example[:example.index("text")].strip() suffix = example[example.index("text") + 4:].strip() - logger.debug("Matched prefix '{}' and suffix '{}'".format(prefix, suffix)) # DB stores empty strings as None, make that work if not prefix: @@ -144,13 +184,11 @@ async def member_proxy(ctx: CommandContext): async with ctx.conn.transaction(): await member.set_proxy_tags(ctx.conn, prefix, suffix) - await ctx.reply_ok("Proxy settings updated." if prefix or suffix else "Proxy settings cleared.") + await ctx.reply_ok( + "Proxy settings updated." if prefix or suffix else "Proxy settings cleared. If you meant to set your proxy tags, type `pk;help proxy` for help.") -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)) - +async def member_delete(ctx: CommandContext, member: 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) if not await ctx.confirm_text(ctx.message.author, ctx.message.channel, member.hid, delete_confirm_msg): diff --git a/src/pluralkit/bot/commands/message_commands.py b/src/pluralkit/bot/commands/message_commands.py index d5ceebf6..58a2d1cd 100644 --- a/src/pluralkit/bot/commands/message_commands.py +++ b/src/pluralkit/bot/commands/message_commands.py @@ -1,62 +1,18 @@ -from pluralkit.bot import help from pluralkit.bot.commands import * -logger = logging.getLogger("pluralkit.commands") - - -async def get_message_contents(client: discord.Client, channel_id: int, message_id: int): - channel = client.get_channel(channel_id) - if channel: - try: - original_message = await channel.get_message(message_id) - return original_message.content or None - except (discord.errors.Forbidden, discord.errors.NotFound): - pass - - return None async def message_info(ctx: CommandContext): - mid_str = ctx.pop_str(CommandError("You must pass a message ID.", help=help.message_lookup)) + mid_str = ctx.pop_str(CommandError("You must pass a message ID.")) try: mid = int(mid_str) except ValueError: - raise CommandError("You must pass a valid number as a message ID.", help=help.message_lookup) + raise CommandError("You must pass a valid number as a message ID.") # Find the message in the DB message = await db.get_message(ctx.conn, mid) if not message: - raise CommandError("Message with ID '{}' not found.".format(mid)) + raise CommandError( + "Message with ID '{}' not found. Are you sure it's a message proxied by PluralKit?".format(mid)) - # Get the original sender of the messages - try: - original_sender = await ctx.client.get_user_info(message.sender) - except discord.NotFound: - # Account was since deleted - rare but we're handling it anyway - original_sender = None - - embed = discord.Embed() - embed.timestamp = discord.utils.snowflake_time(mid) - embed.colour = discord.Colour.blue() - - if message.system_name: - system_value = "{} (`{}`)".format(message.system_name, message.system_hid) - else: - system_value = "`{}`".format(message.system_hid) - embed.add_field(name="System", value=system_value) - - embed.add_field(name="Member", value="{} (`{}`)".format(message.name, message.hid)) - - if original_sender: - sender_name = "{}#{}".format(original_sender.name, original_sender.discriminator) - else: - sender_name = "(deleted account {})".format(message.sender) - - embed.add_field(name="Sent by", value=sender_name) - - message_content = await get_message_contents(ctx.client, message.channel, message.mid) - embed.description = message_content or "(unknown, message deleted)" - - embed.set_author(name=message.name, icon_url=message.avatar_url or discord.Embed.Empty) - - await ctx.reply_ok(embed=embed) + await ctx.reply_ok(embed=await embeds.message_card(ctx.client, message)) diff --git a/src/pluralkit/bot/commands/misc_commands.py b/src/pluralkit/bot/commands/misc_commands.py index bdf6c898..5ca2c5c3 100644 --- a/src/pluralkit/bot/commands/misc_commands.py +++ b/src/pluralkit/bot/commands/misc_commands.py @@ -1,61 +1,57 @@ import io import json -import logging import os -from typing import List - from discord.utils import oauth_url -import pluralkit.utils -from pluralkit.bot import utils, embeds +from pluralkit.bot import help from pluralkit.bot.commands import * -logger = logging.getLogger("pluralkit.commands") - -async def show_help(ctx: CommandContext): - embed = embeds.status("") - embed.title = "PluralKit Help" - embed.set_footer(text="By Astrid (Ske#6201; pk;member qoxvy) | GitHub: https://github.com/xSke/PluralKit/") - - category = ctx.pop_str() if ctx.has_next() else None - - from pluralkit.bot.help import help_pages - if category in help_pages: - for name, text in help_pages[category]: - if name: - embed.add_field(name=name, value=text) - else: - embed.description = text +async def help_root(ctx: CommandContext): + if ctx.match("commands"): + await ctx.reply(help.all_commands) + elif ctx.match("proxy"): + await ctx.reply(help.proxy_guide) else: - raise CommandError("Unknown help page '{}'.".format(category)) - - await ctx.reply(embed=embed) + await ctx.reply(help.root) async def invite_link(ctx: CommandContext): client_id = os.environ["CLIENT_ID"] permissions = discord.Permissions() + + # So the bot can actually add the webhooks it needs to do the proxy functionality permissions.manage_webhooks = True + + # So the bot can respond with status, error, and success messages permissions.send_messages = True + + # So the bot can delete channels permissions.manage_messages = True + + # So the bot can respond with extended embeds, ex. member cards permissions.embed_links = True + + # So the bot can send images too permissions.attach_files = True + + # (unsure if it needs this, actually, might be necessary for message lookup) permissions.read_message_history = True + + # So the bot can add reactions for confirm/deny prompts permissions.add_reactions = True url = oauth_url(client_id, permissions) - logger.debug("Sending invite URL: {}".format(url)) await ctx.reply_ok("Use this link to add PluralKit to your server: {}".format(url)) async def export(ctx: CommandContext): system = await ctx.ensure_system() - members = await db.get_all_members(ctx.conn, system.id) - accounts = await db.get_linked_accounts(ctx.conn, system.id) - switches = await pluralkit.utils.get_front_history(ctx.conn, system.id, 999999) + members = await system.get_members(ctx.conn) + accounts = await system.get_linked_account_ids(ctx.conn) + switches = await system.get_switches(ctx.conn, 999999) data = { "name": system.name, @@ -81,10 +77,10 @@ async def export(ctx: CommandContext): "accounts": [str(uid) for uid in accounts], "switches": [ { - "timestamp": timestamp.isoformat(), - "members": [member.hid for member in members] - } for timestamp, members in switches - ] + "timestamp": switch.timestamp.isoformat(), + "members": [member.hid for member in await switch.fetch_members(ctx.conn)] + } for switch in switches + ] # TODO: messages } f = io.BytesIO(json.dumps(data).encode("utf-8")) diff --git a/src/pluralkit/bot/commands/mod_commands.py b/src/pluralkit/bot/commands/mod_commands.py index 1d9ba30a..47c05fcc 100644 --- a/src/pluralkit/bot/commands/mod_commands.py +++ b/src/pluralkit/bot/commands/mod_commands.py @@ -1,7 +1,5 @@ from pluralkit.bot.commands import * -logger = logging.getLogger("pluralkit.commands") - async def set_log(ctx: CommandContext): if not ctx.message.author.guild_permissions.administrator: diff --git a/src/pluralkit/bot/commands/switch_commands.py b/src/pluralkit/bot/commands/switch_commands.py index f29e0902..78629c9a 100644 --- a/src/pluralkit/bot/commands/switch_commands.py +++ b/src/pluralkit/bot/commands/switch_commands.py @@ -1,5 +1,4 @@ import dateparser -import humanize from datetime import datetime from typing import List @@ -7,24 +6,34 @@ import pluralkit.utils from pluralkit.bot import help from pluralkit.bot.commands import * from pluralkit.member import Member +from pluralkit.utils import display_relative -logger = logging.getLogger("pluralkit.commands") + +async def switch_root(ctx: CommandContext): + if not ctx.has_next(): + raise CommandError("You must use a subcommand. For a list of subcommands, type `pk;switch help`.") + + if ctx.match("out"): + await switch_out(ctx) + elif ctx.match("move"): + await switch_move(ctx) + elif ctx.match("delete") or ctx.match("remove") or ctx.match("erase") or ctx.match("cancel"): + await switch_delete(ctx) + elif ctx.match("help"): + await ctx.reply(help.member_commands) + else: + await switch_member(ctx) async def switch_member(ctx: CommandContext): system = await ctx.ensure_system() if not ctx.has_next(): - raise CommandError("You must pass at least one member name or ID to register a switch to.", - help=help.switch_register) + raise CommandError("You must pass at least one member name or ID to register a switch to.") members: List[Member] = [] - for member_name in ctx.remaining().split(" "): - # Find the member - member = await utils.get_member_fuzzy(ctx.conn, system.id, member_name) - if not member: - raise CommandError("Couldn't find member \"{}\".".format(member_name)) - members.append(member) + while ctx.has_next(): + members.append(await ctx.pop_member()) # Compare requested switch IDs and existing fronter IDs to check for existing switches # Lists, because order matters, it makes sense to just swap fronters @@ -40,10 +49,7 @@ async def switch_member(ctx: CommandContext): raise CommandError("Duplicate members in member list.") # Log the switch - async with ctx.conn.transaction(): - switch_id = await db.add_switch(ctx.conn, system_id=system.id) - for member in members: - await db.add_switch_member(ctx.conn, switch_id=switch_id, member_id=member.id) + await system.add_switch(ctx.conn, members) if len(members) == 1: await ctx.reply_ok("Switch registered. Current fronter is now {}.".format(members[0].name)) @@ -55,20 +61,52 @@ async def switch_member(ctx: CommandContext): async def switch_out(ctx: CommandContext): system = await ctx.ensure_system() - # Get current fronters - fronters, _ = await pluralkit.utils.get_fronter_ids(ctx.conn, system_id=system.id) - if not fronters: + switch = await system.get_latest_switch(ctx.conn) + if switch and not switch.members: raise CommandError("There's already no one in front.") # Log it, and don't log any members - await db.add_switch(ctx.conn, system_id=system.id) + await system.add_switch(ctx.conn, []) await ctx.reply_ok("Switch-out registered.") +async def switch_delete(ctx: CommandContext): + system = await ctx.ensure_system() + + last_two_switches = await system.get_switches(ctx.conn, 2) + if not last_two_switches: + raise CommandError("You do not have a logged switch to delete.") + + last_switch = last_two_switches[0] + next_last_switch = last_two_switches[1] if len(last_two_switches) > 1 else None + + last_switch_members = ", ".join([member.name for member in await last_switch.fetch_members(ctx.conn)]) + last_switch_time = display_relative(last_switch.timestamp) + + if next_last_switch: + next_last_switch_members = ", ".join([member.name for member in await next_last_switch.fetch_members(ctx.conn)]) + next_last_switch_time = display_relative(next_last_switch.timestamp) + msg = await ctx.reply_warn("This will delete the latest switch ({}, {} ago). The next latest switch is {} ({} ago). Is this okay?".format(last_switch_members, last_switch_time, next_last_switch_members, next_last_switch_time)) + else: + msg = await ctx.reply_warn("This will delete the latest switch ({}, {} ago). You have no other switches logged. Is this okay?".format(last_switch_members, last_switch_time)) + + if not await ctx.confirm_react(ctx.message.author, msg): + raise CommandError("Switch deletion cancelled.") + + await last_switch.delete(ctx.conn) + + if next_last_switch: + # lol block scope amirite + # but yeah this is fine + await ctx.reply_ok("Switch deleted. Next latest switch is now {} ({} ago).".format(next_last_switch_members, next_last_switch_time)) + else: + await ctx.reply_ok("Switch deleted. You now have no logged switches.") + + async def switch_move(ctx: CommandContext): system = await ctx.ensure_system() if not ctx.has_next(): - raise CommandError("You must pass a time to move the switch to.", help=help.switch_move) + raise CommandError("You must pass a time to move the switch to.") # Parse the time to move to new_time = dateparser.parse(ctx.remaining(), languages=["en"], settings={ @@ -76,11 +114,11 @@ async def switch_move(ctx: CommandContext): "RETURN_AS_TIMEZONE_AWARE": False }) if not new_time: - raise CommandError("'{}' can't be parsed as a valid time.".format(ctx.remaining()), help=help.switch_move) + raise CommandError("'{}' can't be parsed as a valid time.".format(ctx.remaining())) # Make sure the time isn't in the future if new_time > datetime.utcnow(): - raise CommandError("Can't move switch to a time in the future.", help=help.switch_move) + raise CommandError("Can't move switch to a time in the future.") # Make sure it all runs in a big transaction for atomicity async with ctx.conn.transaction(): @@ -94,19 +132,25 @@ async def switch_move(ctx: CommandContext): second_last_timestamp, _ = last_two_switches[1] if new_time < second_last_timestamp: - time_str = humanize.naturaltime(pluralkit.utils.fix_time(second_last_timestamp)) + time_str = display_relative(second_last_timestamp) raise CommandError( - "Can't move switch to before last switch time ({}), as it would cause conflicts.".format(time_str)) + "Can't move switch to before last switch time ({} ago), as it would cause conflicts.".format(time_str)) # Display the confirmation message w/ humanized times members = ", ".join([member.name for member in last_fronters]) or "nobody" last_absolute = last_timestamp.isoformat(sep=" ", timespec="seconds") - last_relative = humanize.naturaltime(pluralkit.utils.fix_time(last_timestamp)) + last_relative = display_relative(last_timestamp) new_absolute = new_time.isoformat(sep=" ", timespec="seconds") - new_relative = humanize.naturaltime(pluralkit.utils.fix_time(new_time)) + new_relative = display_relative(new_time) # Confirm with user - switch_confirm_message = await ctx.reply("This will move the latest switch ({}) from {} ({}) to {} ({}). Is this OK?".format(members, last_absolute, last_relative, new_absolute, new_relative)) + switch_confirm_message = await ctx.reply( + "This will move the latest switch ({}) from {} ({} ago) to {} ({} ago). Is this OK?".format(members, + last_absolute, + last_relative, + new_absolute, + new_relative)) + if not await ctx.confirm_react(ctx.message.author, switch_confirm_message): raise CommandError("Switch move cancelled.") @@ -116,6 +160,3 @@ async def switch_move(ctx: CommandContext): # Change the switch in the DB await db.move_last_switch(ctx.conn, system.id, switch_id, new_time) await ctx.reply_ok("Switch moved.") - - - diff --git a/src/pluralkit/bot/commands/system_commands.py b/src/pluralkit/bot/commands/system_commands.py index 5d10461b..ede7a2cf 100644 --- a/src/pluralkit/bot/commands/system_commands.py +++ b/src/pluralkit/bot/commands/system_commands.py @@ -3,28 +3,73 @@ import humanize from datetime import datetime, timedelta import pluralkit.bot.embeds -import pluralkit.utils from pluralkit.bot import help from pluralkit.bot.commands import * -from pluralkit.errors import ExistingSystemError, UnlinkingLastAccountError, PluralKitError, AccountAlreadyLinkedError - -logger = logging.getLogger("pluralkit.commands") +from pluralkit.errors import ExistingSystemError, UnlinkingLastAccountError, AccountAlreadyLinkedError +from pluralkit.utils import display_relative -async def system_info(ctx: CommandContext): - if ctx.has_next(): - system = await ctx.pop_system() +async def system_root(ctx: CommandContext): + # Commands that operate without a specified system (usually defaults to the executor's own system) + if ctx.match("name") or ctx.match("rename"): + await system_name(ctx) + elif ctx.match("description"): + await system_description(ctx) + elif ctx.match("avatar") or ctx.match("icon"): + await system_avatar(ctx) + elif ctx.match("tag"): + await system_tag(ctx) + elif ctx.match("new") or ctx.match("register") or ctx.match("create") or ctx.match("init"): + await system_new(ctx) + elif ctx.match("delete") or ctx.match("delete") or ctx.match("erase"): + await system_delete(ctx) + elif ctx.match("front") or ctx.match("fronter") or ctx.match("fronters"): + await system_fronter(ctx, await ctx.ensure_system()) + elif ctx.match("fronthistory"): + await system_fronthistory(ctx, await ctx.ensure_system()) + elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): + await system_frontpercent(ctx, await ctx.ensure_system()) + elif ctx.match("help"): + await ctx.reply(help.system_commands) + elif ctx.match("set"): + await system_set(ctx) + elif not ctx.has_next(): + # (no argument, command ends here, default to showing own system) + await system_info(ctx, await ctx.ensure_system()) else: - system = await ctx.ensure_system() + # If nothing matches, the next argument is likely a system name/ID, so delegate + # to the specific system root + await specified_system_root(ctx) + +async def specified_system_root(ctx: CommandContext): + # Commands that operate on a specified system (ie. not necessarily the command executor's) + system_name = ctx.pop_str() + system = await utils.get_system_fuzzy(ctx.conn, ctx.client, system_name) + if not system: + raise CommandError( + "Unable to find system `{}`. If you meant to run a command, type `pk;system help` for a list of system commands.".format( + system_name)) + + if ctx.match("front") or ctx.match("fronter"): + await system_fronter(ctx, system) + elif ctx.match("fronthistory"): + await system_fronthistory(ctx, system) + elif ctx.match("frontpercent") or ctx.match("frontbreakdown") or ctx.match("frontpercentage"): + await system_frontpercent(ctx, system) + else: + await system_info(ctx, system) + + +async def system_info(ctx: CommandContext, system: System): await ctx.reply(embed=await pluralkit.bot.embeds.system_card(ctx.conn, ctx.client, system)) -async def new_system(ctx: CommandContext): - system_name = ctx.remaining() or None +async def system_new(ctx: CommandContext): + new_name = ctx.remaining() or None try: - await System.create_system(ctx.conn, ctx.message.author.id, system_name) + await System.create_system(ctx.conn, ctx.message.author.id, new_name) except ExistingSystemError as e: raise CommandError(e.message) @@ -95,9 +140,10 @@ async def system_avatar(ctx: CommandContext): await ctx.reply_ok("System avatar {}.".format("updated" if new_avatar_url else "cleared")) -async def system_link(ctx: CommandContext): +async def account_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)) + account_name = ctx.pop_str(CommandError( + "You must pass an account to link this system to. You can either use a \\@mention, or a raw account ID.")) # 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 @@ -105,14 +151,15 @@ async def system_link(ctx: CommandContext): # Find account to link linkee = await utils.parse_mention(ctx.client, account_name) if not linkee: - raise CommandError("Account not found.") + raise CommandError("Account `{}` not found.".format(account_name)) # Make sure account doesn't already have a system account_system = await System.get_by_account(ctx.conn, linkee.id) if account_system: raise CommandError(AccountAlreadyLinkedError(account_system).message) - msg = await ctx.reply("{}, please confirm the link by clicking the \u2705 reaction on this message.".format(linkee.mention)) + msg = await ctx.reply( + "{}, please confirm the link by clicking the \u2705 reaction on this message.".format(linkee.mention)) if not await ctx.confirm_react(linkee, msg): raise CommandError("Account link cancelled.") @@ -120,7 +167,7 @@ async def system_link(ctx: CommandContext): await ctx.reply_ok("Account linked to system.") -async def system_unlink(ctx: CommandContext): +async def account_unlink(ctx: CommandContext): system = await ctx.ensure_system() try: @@ -131,24 +178,18 @@ async def system_unlink(ctx: CommandContext): await ctx.reply_ok("Account unlinked.") -async def system_fronter(ctx: CommandContext): - if ctx.has_next(): - system = await ctx.pop_system() - else: - system = await ctx.ensure_system() - +async def system_fronter(ctx: CommandContext, system: System): embed = await embeds.front_status(await system.get_latest_switch(ctx.conn), ctx.conn) await ctx.reply(embed=embed) -async def system_fronthistory(ctx: CommandContext): - if ctx.has_next(): - system = await ctx.pop_system() - else: - system = await ctx.ensure_system() - +async def system_fronthistory(ctx: CommandContext, system: System): lines = [] front_history = await pluralkit.utils.get_front_history(ctx.conn, system.id, count=10) + + if not front_history: + raise CommandError("You have no logged switches. Use `pk;switch´ to start logging.") + for i, (timestamp, members) in enumerate(front_history): # Special case when no one's fronting if len(members) == 0: @@ -158,13 +199,13 @@ async def system_fronthistory(ctx: CommandContext): # Make proper date string time_text = timestamp.isoformat(sep=" ", timespec="seconds") - rel_text = humanize.naturaltime(pluralkit.utils.fix_time(timestamp)) + rel_text = display_relative(timestamp) delta_text = "" if i > 0: last_switch_time = front_history[i - 1][0] - delta_text = ", for {}".format(humanize.naturaldelta(timestamp - last_switch_time)) - lines.append("**{}** ({}, {}{})".format(name, time_text, rel_text, delta_text)) + delta_text = ", for {}".format(display_relative(timestamp - last_switch_time)) + lines.append("**{}** ({}, {} ago{})".format(name, time_text, rel_text, delta_text)) embed = embeds.status("\n".join(lines) or "(none)") embed.title = "Past switches" @@ -183,9 +224,7 @@ async def system_delete(ctx: CommandContext): await ctx.reply_ok("System deleted.") -async def system_frontpercent(ctx: CommandContext): - system = await ctx.ensure_system() - +async def system_frontpercent(ctx: CommandContext, system: System): # Parse the time limit (will go this far back) if ctx.remaining(): before = dateparser.parse(ctx.remaining(), languages=["en"], settings={ @@ -265,6 +304,6 @@ async def system_frontpercent(ctx: CommandContext): embed.add_field(name=member.name if member else "(no fronter)", value="{}% ({})".format(percent, humanize.naturaldelta(front_time))) - embed.set_footer(text="Since {} ({})".format(span_start.isoformat(sep=" ", timespec="seconds"), - humanize.naturaltime(pluralkit.utils.fix_time(span_start)))) + embed.set_footer(text="Since {} ({} ago)".format(span_start.isoformat(sep=" ", timespec="seconds"), + display_relative(span_start))) await ctx.reply(embed=embed) diff --git a/src/pluralkit/bot/embeds.py b/src/pluralkit/bot/embeds.py index b55a9c2f..4ab8ccb0 100644 --- a/src/pluralkit/bot/embeds.py +++ b/src/pluralkit/bot/embeds.py @@ -2,12 +2,12 @@ import discord import humanize from typing import Tuple -import pluralkit +from pluralkit import db from pluralkit.bot.utils import escape from pluralkit.member import Member from pluralkit.switch import Switch from pluralkit.system import System -from pluralkit.utils import get_fronters +from pluralkit.utils import get_fronters, display_relative def truncate_field_name(s: str) -> str: @@ -52,7 +52,8 @@ def status(text: str) -> discord.Embed: return embed -def exception_log(message_content, author_name, author_discriminator, author_id, server_id, channel_id) -> 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 = truncate_title(message_content) @@ -82,7 +83,8 @@ async def system_card(conn, client: discord.Client, system: System) -> discord.E 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=truncate_field_body(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): @@ -185,7 +187,53 @@ async def front_status(switch: Switch, conn) -> discord.Embed: if switch.timestamp: embed.add_field(name="Since", value="{} ({})".format(switch.timestamp.isoformat(sep=" ", timespec="seconds"), - humanize.naturaltime(pluralkit.utils.fix_time(switch.timestamp)))) + display_relative(switch.timestamp))) else: embed = error("No switches logged.") return embed + + +async def get_message_contents(client: discord.Client, channel_id: int, message_id: int): + channel = client.get_channel(channel_id) + if channel: + try: + original_message = await channel.get_message(message_id) + return original_message.content or None + except (discord.errors.Forbidden, discord.errors.NotFound): + pass + + return None + + +async def message_card(client: discord.Client, message: db.MessageInfo): + # Get the original sender of the messages + try: + original_sender = await client.get_user_info(message.sender) + except discord.NotFound: + # Account was since deleted - rare but we're handling it anyway + original_sender = None + + embed = discord.Embed() + embed.timestamp = discord.utils.snowflake_time(message.mid) + embed.colour = discord.Colour.blue() + + if message.system_name: + system_value = "{} (`{}`)".format(message.system_name, message.system_hid) + else: + system_value = "`{}`".format(message.system_hid) + embed.add_field(name="System", value=system_value) + + embed.add_field(name="Member", value="{} (`{}`)".format(message.name, message.hid)) + + if original_sender: + sender_name = "{}#{}".format(original_sender.name, original_sender.discriminator) + else: + sender_name = "(deleted account {})".format(message.sender) + + embed.add_field(name="Sent by", value=sender_name) + + message_content = await get_message_contents(client, message.channel, message.mid) + embed.description = message_content or "(unknown, message deleted)" + + embed.set_author(name=message.name, icon_url=message.avatar_url or discord.Embed.Empty) + return embed diff --git a/src/pluralkit/bot/help.py b/src/pluralkit/bot/help.py index dc735dd2..6e770748 100644 --- a/src/pluralkit/bot/help.py +++ b/src/pluralkit/bot/help.py @@ -1,177 +1,123 @@ -categories = ("Help categories", """`pk;help system` - Details on system configuration. -`pk;help member` - Details on member configuration. -`pk;help proxy` - Details on message proxying. -`pk;help switch` - Details on switch logging. -`pk;help mod` - Details on moderator operations. -`pk;help import` - Details on data import from other services.""") -getting_started = ("Getting started", """To get started using the bot, try running the following commands: +all_commands = """ +**All commands** +``` +pk;system [system] +pk;system new [system name +pk;system rename [new name] +pk;system description [new description] +pk;system avatar [new avatar url] +pk;system tag [new system tag] +pk;system delete +pk;system [system] fronter +pk;system [system] fronthistory +pk;system [system] frontpercent +pk;member new +pk;member +pk;member rename +pk;member description [new description] +pk;member avatar [new avatar url] +pk;member proxy [example match] +pk;member pronouns [new pronouns] +pk;member color [new color] +pk;member birthday [new birthday] +pk;member delete +pk;switch [...] +pk;switch move