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,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)
client.run(bot_token)

View File

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

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)

View File

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

View File

@ -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 <member name>
pk;member <member>
pk;member <member> rename <new name>
pk;member <member> description [new description]
pk;member <member> avatar [new avatar url]
pk;member <member> proxy [example match]
pk;member <member> pronouns [new pronouns]
pk;member <member> color [new color]
pk;member <member> birthday [new birthday]
pk;member <member> delete
pk;switch <member> [<other member>...]
pk;switch move <time to move>
pk;switch out
pk;switch delete
pk;link <other account>
pk;unlink
pk;message <message id>
pk;log <log channel>
pk;invite
pk;import
pk;export
pk;token
pk;token refresh
```
**Command notes**
Parameters in <angle brackets> are required, [square brackets] are optional .
System references can be a system ID, a Discord account ID or a \\@mention.
Member references can be a member ID or, for your own system, a member name.
Leaving an optional parameter blank will often clear the relevant value.
"""
system_commands = """
**System 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;link <other account>
pk;unlink
```
**Command notes**
Parameters in <angle brackets> are required, [square brackets] are optional .
System references can be a system ID, a Discord account ID or a \\@mention.
Leaving an optional parameter blank will often clear the relevant value.
"""
member_commands = """
**Member commands**
```
pk;member new <member name>
pk;member <member>
pk;member <member> rename <new name>
pk;member <member> description [new description]
pk;member <member> avatar [new avatar url]
pk;member <member> proxy [example match]
pk;member <member> pronouns [new pronouns]
pk;member <member> color [new color]
pk;member <member> birthday [new birthday]
pk;member <member> delete
pk;switch <member> [<other member>...]
pk;switch move <time to move>
pk;switch out
pk;switch delete
```
**Command notes**
Parameters in <angle brackets> are required, [square brackets] are optional .
Member references can be a member ID or, for your own system, a member name.
Leaving an optional parameter blank will often clear the relevant value.
"""
proxy_guide = """
**Proxying**
Proxying through PluralKit lets system members have their own faux-account with their name and avatar.
You'll type a message from your account in *proxy tags*, and PluralKit will recognize those tags and repost the message with the proper details.
To set up a member's proxy tag, use the `pk;member <name> proxy [example match]` command.
You'll need to give the bot an "example match". Imagine you're proxying the word "text", and add that to the end.
For example: `pk;member John proxy [text]`. That will set the member John up to use square brackets as proxy tags.
Now saying something like `[hello world]` will proxy the text "hello world" with John's name and avatar.
You can also use other symbols, letters, numbers, et cetera, as prefixes, suffixes, or both. `J:text`, `$text` and `text]` are also examples of valid example matches.
"""
root = """
**PluralKit**
PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.
**Getting started**
To get started using the bot, try running the following commands.
**1**. `pk;system new` - Create a system if you haven't already
**2**. `pk;member add John` - Add a new member to your system
**3**. `pk;member proxy John [text]` - Set up square brackets as proxy tags
**4**. You're done! See the other help pages for more commands.""")
discord_link = (
"Discord",
"""For feedback, bug reports, suggestions, or just chatting, join our Discord: https://discord.gg/PczBt78""")
registering_system = ("Registering a new system",
"""To use PluralKit, you must register a system for your account. You can use the `pk;system new` command for this. You can optionally add a system name after the command.""")
lookup_system = ("Looking up a system", """To look up a system's details, you can use the `pk;system` command.
For example:
`pk;system` - Shows details of your own system.
`pk;system abcde` - Shows details of the system with the ID `abcde`.
`pk;system @JohnsAccount` - Shows details of the system linked to @JohnsAccount.""")
edit_system = ("Editing system properties", """You can use the `pk;system` commands to change your system properties. The properties you can change are name, description, and tag.
For example:
`pk;system name My System` - sets your system name to "My System".
`pk;system description A really cool system.` - sets your system description.
`pk;system tag [MS]` - Sets the tag (which will be displayed after member names in messages) to "[MS]".
`pk;system avatar https://placekitten.com/400/400` - Changes your system's avatar to a linked image.
**3**. `pk;member John proxy [text]` - Set up square brackets as proxy tags
**4**. You're done!
If you don't specify any value, the property will be cleared.""")
link_account = ("Linking accounts", """If your system has multiple accounts, you can link all of them to your system, and you can use the bot from all of those accounts.
For example:
`pk;system link @MyOtherAccount` - Links @MyOtherAccount to your system.
You'll need to confirm the link from the other account.""")
unlink_accounts = (
"Unlinking accounts", """If you need to unlink an account, you can do that with the `pk;system unlink` command.""")
add_member = ("Adding a new member", """To add a new member to your system, use the `pk;member new` command. You'll need to add a member name.
For example:
`pk;member new John`""")
lookup_member = ("Looking up a member", """To look up a member's details, you can use the `pk;member` command.
For example:
`pk;member John` - Shows details of the member in your system named John.
`pk;member abcde` - Shows details of the member with the ID `abcde`.
You can use member IDs to look up members in other systems.""")
edit_member = ("Editing member properties", """You can use the `pk;member` commands to change a member's properties. The properties you can change are name, description, color, pronouns, birthdate and avatar.
For example:
`pk;member rename John Joe` - Changes John's name to Joe.
`pk;member description John Pretty cool dude.` - Changes John's description.
`pk;member color John #ff0000` - Changes John's color to red.
`pk;member pronouns John he/him` - Changes John's pronouns.
`pk;member birthdate John 1996-02-27` - Changes John's birthdate to Feb 27, 1996. (Must be YYYY-MM-DD format).
`pk;member birthdate John 02-27` - Changes John's birthdate to February 27th, with no year.
`pk;member avatar John https://placekitten.com/400/400` - Changes John's avatar to a linked image.
`pk;member avatar John @JohnsAccount` - Changes John's avatar to the avatar of the mentioned account.
If you don't specify any value, the property will be cleared.""")
remove_member = ("Removing a member", """If you want to delete a member, you can use the `pk;member delete` command.
For example:
`pk;member delete John`
You will need to confirm the deletion.""")
member_proxy = ("Setting up member proxying", """To register a member for proxying, use the `pk;member proxy` command.
You will need to pass an "example proxy" message containing "text", surrounded by the brackets or prefixes you want to select.
For example:
`pk;member proxy John [text]` - Registers John to use [square brackets] as proxy brackets.
`pk;member proxy John J:text` - Registers John to use the prefix "J:".
After setting proxy tags, you can use them in any message, and they'll be interpreted by the bot and proxied appropriately.""")
system_tag = ("Setting your system tag", """To set your system tag, use the `pk;system tag` command.
The tag is appended to the name of all proxied messages.
For example:
`pk;system tag [MS]` - Sets your system tag to "[MS]".
`pk;system tag :heart:` - Sets your system tag to the heart emoji.
Note you can only use default Discord emojis, not custom server emojis.""")
message_lookup = ("Looking up a message", """You can look up a message by its ID using the `pk;message` command.
For example:
`pk;message 467638937402212352` - Shows information about the message by that ID.
To get a message ID, turn on Developer Mode in your client's Appearance settings, right click, and press "Copy ID".""")
message_delete = ("Deleting messages",
"""You can delete your own messages by reacting with the ❌ emoji on it. Note that this only works on messages sent from your account.""")
switch_register = ("Registering a switch", """To log a switch in your system, use the `pk;switch` command.
For example:
`pk;switch John` - Registers a switch with John as fronter.
`pk;switch John Jill` - Registers a switch John and Jill as co-fronters.""")
switch_out = ("Switching out", """You can use the `pk;switch out` command to register a switch with no one in front.""")
switch_move = ("Moving a switch", """You can move the latest switch you have registered using the `pk;switch move` command.
This is useful if you log the switch a while after it happened, and you want to properly backdate it in the history.
For example:
`pk;switch move 10 minutes ago` - Moves the latest switch to 10 minutes ago
`pk;switch move 11pm EST` - Moves the latest switch to 11pm EST
Note that you can't move the switch further back than the second-last logged switch, and you can't move a switch to a time in the future.
The default time zone for absolute times is UTC, but you can specify other time zones in the command itself, as given in the example.""")
front_history = ("Viewing fronting history", """To view front history, you can use the `pk;front` and `pk;front history` commands.
For example:
`pk;front` - Shows the current fronter(s) in your own system.
`pk;front abcde` - Shows the current fronter in the system with the ID `abcde`.
`pk;front history` - Shows the past 10 switches in your own system.
`pk;front history @JohnsAccount` - Shows the past 10 switches in the system linked to @JohnsAccount.""")
front_breakdown = ("Viewing a front breakdown", """To see a per-member breakdown of your switches, use the `pk;front percent` command. You can optionally give it a time limit to only count switches after that point.
For example:
`pk;front percent` - Shows a front breakdown for your system since you started logging switches
`pk;front percent 1 day` - Shows a front breakdown for your system for the past day
`pk;front percent Jan 1st 2018` - Shows a front breakdown for your system since January 1st, 2018
Note that the percentages don't necessarily add up to 100%, as multiple members can be listed as fronting at a time.""")
logging_channel = ("Setting up a logging channel", """To designate a channel for the bot to log posted messages to, use the `pk;log` command.
For example:
`pk;log #message-log` - Configures the bot to log to #message-log.""")
tupperware_import = ("Importing from Tupperware", """If you already have a registered system on Tupperware, you can use the `pk;import tupperware` command to import it into PluralKit.
Note the command only works on a server and channel where the Tupperware bot is already present.""")
help_pages = {
None: [
(None,
"""PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more."""),
getting_started,
categories,
discord_link
],
"system": [
registering_system,
lookup_system,
edit_system,
link_account,
unlink_accounts
],
"member": [
add_member,
lookup_member,
edit_member,
remove_member
],
"proxy": [
member_proxy,
system_tag,
message_lookup,
message_delete
],
"switch": [
switch_register,
switch_out,
switch_move,
front_history,
front_breakdown
],
"mod": [
(None,
"Note that all moderation commands require you to have administrator privileges on the server they're used on."),
logging_channel
],
"import": [
tupperware_import
]
}
**More information**
For a full list of commands, type `pk;help commands`.
For a more in-depth explanation of proxying, type `pk;help proxy`.
If you're an existing user of the Tupperware proxy bot, type `pk;import` to import your data from there.
"""

View File

@ -1,5 +1,4 @@
import discord
import logging
from io import BytesIO
from typing import Optional
@ -9,7 +8,6 @@ from pluralkit.bot.channel_logger import ChannelLogger
from pluralkit.member import Member
from pluralkit.system import System
logger = logging.getLogger("pluralkit.bot.proxy")
class ProxyError(Exception):
pass
@ -21,6 +19,7 @@ def fix_webhook(webhook: discord.Webhook) -> discord.Webhook:
webhook._adapter.http = None
return webhook
async def get_or_create_webhook_for_channel(conn, bot_user: discord.User, channel: discord.TextChannel):
# First, check if we have one saved in the DB
webhook_from_db = await db.get_webhook(conn, channel.id)
@ -46,7 +45,8 @@ async def get_or_create_webhook_for_channel(conn, bot_user: discord.User, channe
# If not, we create one and save it
created_webhook = await channel.create_webhook(name="PluralKit Proxy Webhook")
except discord.Forbidden:
raise ProxyError("PluralKit does not have the \"Manage Webhooks\" permission, and thus cannot proxy your message. Please contact a server administrator.")
raise ProxyError(
"PluralKit does not have the \"Manage Webhooks\" permission, and thus cannot proxy your message. Please contact a server administrator.")
await db.add_webhook(conn, channel.id, created_webhook.id, created_webhook.token)
return fix_webhook(created_webhook)
@ -74,9 +74,13 @@ async def send_proxy_message(conn, original_message: discord.Message, system: Sy
# Bounds check the combined name to avoid silent erroring
full_username = "{} {}".format(member.name, system.tag or "").strip()
if len(full_username) < 2:
raise ProxyError("The webhook's name, `{}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.".format(full_username))
raise ProxyError(
"The webhook's name, `{}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.".format(
full_username))
if len(full_username) > 32:
raise ProxyError("The webhook's name, `{}`, is longer than 32 characters, and thus cannot be proxied. Please change the member name or use a shorter system tag.".format(full_username))
raise ProxyError(
"The webhook's name, `{}`, is longer than 32 characters, and thus cannot be proxied. Please change the member name or use a shorter system tag.".format(
full_username))
try:
sent_message = await webhook.send(
@ -122,7 +126,8 @@ async def send_proxy_message(conn, original_message: discord.Message, system: Sy
try:
await original_message.delete()
except discord.Forbidden:
raise ProxyError("PluralKit does not have permission to delete user messages. Please contact a server administrator.")
raise ProxyError(
"PluralKit does not have permission to delete user messages. Please contact a server administrator.")
async def try_proxy_message(conn, message: discord.Message, logger: ChannelLogger, bot_user: discord.User) -> bool:
@ -153,7 +158,7 @@ async def try_proxy_message(conn, message: discord.Message, logger: ChannelLogge
# So, we now have enough information to successfully proxy a message
async with conn.transaction():
try:
await send_proxy_message(conn, message, system, member, inner_message, logger, bot_user)
await send_proxy_message(conn, message, system, member, inner_message, logger, bot_user)
except ProxyError as e:
await message.channel.send("\u274c {}".format(str(e)))
@ -205,4 +210,4 @@ async def try_delete_by_reaction(conn, client: discord.Client, message_id: int,
# Then delete the original message
await original_message.delete()
await handle_deleted_message(conn, client, message_id, original_message.content, logger)
await handle_deleted_message(conn, client, message_id, original_message.content, logger)

View File

@ -1,12 +1,11 @@
import discord
import logging
import re
import discord
from typing import Optional
from pluralkit import db
from pluralkit.system import System
from pluralkit.member import Member
from pluralkit.system import System
logger = logging.getLogger("pluralkit.utils")
@ -21,7 +20,8 @@ def bounds_check_member_name(new_name, system_tag):
if system_tag:
if len("{} {}".format(new_name, system_tag)) > 32:
return "This name, combined with the system tag ({}), would exceed the maximum length of 32 characters. Please reduce the length of the tag, or use a shorter name.".format(system_tag)
return "This name, combined with the system tag ({}), would exceed the maximum length of 32 characters. Please reduce the length of the tag, or use a shorter name.".format(
system_tag)
async def parse_mention(client: discord.Client, mention: str) -> Optional[discord.User]:
@ -39,18 +39,19 @@ async def parse_mention(client: discord.Client, mention: str) -> Optional[discor
except (ValueError, discord.NotFound):
return None
def parse_channel_mention(mention: str, server: discord.Guild) -> Optional[discord.TextChannel]:
match = re.fullmatch("<#(\\d+)>", mention)
if match:
return server.get_channel(int(match.group(1)))
try:
return server.get_channel(int(mention))
except ValueError:
return None
async def get_system_fuzzy(conn, client: discord.Client, key) -> System:
async def get_system_fuzzy(conn, client: discord.Client, key) -> Optional[System]:
if isinstance(key, discord.User):
return await db.get_system_by_account(conn, account_id=key.id)
@ -80,6 +81,7 @@ async def get_member_fuzzy(conn, system_id: int, key: str, system_only=True) ->
if member is not None:
return member
def sanitize(text):
# Insert a zero-width space in @everyone so it doesn't trigger
return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")

View File

@ -294,6 +294,11 @@ async def add_switch_member(conn, switch_id: int, member_id: int):
logger.debug("Adding switch member (switch={}, member={})".format(switch_id, member_id))
await conn.execute("insert into switch_members (switch, member) values ($1, $2)", switch_id, member_id)
@db_wrap
async def delete_switch(conn, switch_id: int):
logger.debug("Deleting switch (id={})".format(switch_id))
await conn.execute("delete from switches where id = $1", switch_id)
@db_wrap
async def get_server_info(conn, server_id: int):
return await conn.fetchrow("select * from servers where id = $1", server_id)

View File

@ -106,6 +106,8 @@ class Member(namedtuple("Member",
async def set_birthdate(self, conn, new_date: Union[date, str]):
"""
Set or clear the birthdate of a member. To hide the birth year, pass a year of 0001.
If passed a string, will attempt to parse the string as a date.
:raises: InvalidDateStringError
"""

View File

@ -1,4 +1,5 @@
from collections import namedtuple
from datetime import datetime
from typing import List
@ -7,5 +8,13 @@ from pluralkit.member import Member
class Switch(namedtuple("Switch", ["id", "system", "timestamp", "members"])):
id: int
system: int
timestamp: datetime
members: List[int]
async def fetch_members(self, conn) -> List[Member]:
return await db.get_members(conn, self.members)
async def delete(self, conn):
await db.delete_switch(conn, self.id)

View File

@ -128,6 +128,14 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
else:
return None
async def add_switch(self, conn, members: List[Member]):
async with conn.transaction():
switch_id = await db.add_switch(conn, self.id)
# TODO: batch query here
for member in members:
await db.add_switch_member(conn, switch_id, member.id)
def get_member_name_limit(self) -> int:
"""Returns the maximum length a member's name or nickname is allowed to be in order for the member to be proxied. Depends on the system tag."""
if self.tag:

View File

@ -1,19 +1,20 @@
import humanize
import re
import random
import string
from datetime import datetime, timezone
from typing import List, Tuple
from datetime import datetime, timezone, timedelta
from typing import List, Tuple, Union
from urllib.parse import urlparse
from pluralkit import db
from pluralkit.errors import InvalidAvatarURLError
def fix_time(time: datetime):
"""Convert a naive datetime from UTC to local time. humanize's methods expect a local naive time and not a time in UTC."""
# TODO: replace with methods that call humanize directly, to hide implementation details
return time.replace(tzinfo=timezone.utc).astimezone().replace(tzinfo=None)
def display_relative(time: Union[datetime, timedelta]) -> str:
if isinstance(time, datetime):
time = datetime.utcnow() - time
return humanize.naturaldelta(time)
async def get_fronter_ids(conn, system_id) -> (List[int], datetime):