Massive refactor/update/UX improvement dump. Closes #6.

This commit is contained in:
Ske
2018-12-05 11:44:10 +01:00
parent f8e92375b0
commit 72590ec92c
20 changed files with 588 additions and 512 deletions

View File

@@ -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

View File

@@ -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)
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)

View File

@@ -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.")

View File

@@ -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):

View File

@@ -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))

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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.")

View File

@@ -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)