Example #1
0
async def switch_member(ctx: MemberCommandContext, args: List[str]):
    if len(args) == 0:
        return embeds.error("You must pass at least one member name or ID to register a switch to.", help=help.switch_register)

    members: List[Member] = []
    for member_name in args:
        # Find the member
        member = await utils.get_member_fuzzy(ctx.conn, ctx.system.id, member_name)
        if not member:
            return embeds.error("Couldn't find member \"{}\".".format(member_name))
        members.append(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
    member_ids = [member.id for member in members]
    fronter_ids = (await pluralkit.utils.get_fronter_ids(ctx.conn, ctx.system.id))[0]
    if member_ids == fronter_ids:
        if len(members) == 1:
            return embeds.error("{} is already fronting.".format(members[0].name))
        return embeds.error("Members {} are already fronting.".format(", ".join([m.name for m in members])))

    # Also make sure there aren't any duplicates
    if len(set(member_ids)) != len(member_ids):
        return embeds.error("Duplicate members in member list.")

    # Log the switch
    async with ctx.conn.transaction():
        switch_id = await db.add_switch(ctx.conn, system_id=ctx.system.id)
        for member in members:
            await db.add_switch_member(ctx.conn, switch_id=switch_id, member_id=member.id)

    if len(members) == 1:
        return embeds.success("Switch registered. Current fronter is now {}.".format(members[0].name))
    else:
        return embeds.success("Switch registered. Current fronters are now {}.".format(", ".join([m.name for m in members])))
async def message_info(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        return embeds.error("You must pass a message ID.",
                            help=help.message_lookup)

    try:
        mid = int(args[0])
    except ValueError:
        return embeds.error("You must pass a valid number as a message ID.",
                            help=help.message_lookup)

    # Find the message in the DB
    message = await db.get_message(ctx.conn, str(mid))
    if not message:
        raise embeds.error("Message with ID '{}' not found.".format(args[0]))

    # Get the original sender of the messages
    try:
        original_sender = await ctx.client.get_user_info(str(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(str(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)

    if message.content:  # Content can be empty string if there's an attachment
        embed.add_field(name="Content", value=message.content, inline=False)

    embed.set_author(name=message.name,
                     icon_url=message.avatar_url or discord.Embed.Empty)

    return embed
Example #3
0
async def system_fronter(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        if not ctx.system:
            raise NoSystemRegistered()
        system = ctx.system
    else:
        system = await utils.get_system_fuzzy(ctx.conn, ctx.client, args[0])

        if system is None:
            return embeds.error("Can't find system \"{}\".".format(args[0]))

    fronters, timestamp = await pluralkit.utils.get_fronters(
        ctx.conn, system_id=system.id)
    fronter_names = [member.name for member in fronters]

    embed = utils.make_default_embed(None)

    if len(fronter_names) == 0:
        embed.add_field(name="Current fronter", value="(no fronter)")
    elif len(fronter_names) == 1:
        embed.add_field(name="Current fronter", value=fronter_names[0])
    else:
        embed.add_field(name="Current fronters",
                        value=", ".join(fronter_names))

    if timestamp:
        embed.add_field(name="Since",
                        value="{} ({})".format(
                            timestamp.isoformat(sep=" ", timespec="seconds"),
                            humanize.naturaltime(timestamp)))
    return embed
Example #4
0
async def new_system(ctx: CommandContext, args: List[str]):
    if ctx.system:
        return embeds.error(
            "You already have a system registered. To delete your system, use `pk;system delete`, or to unlink your system from this account, use `pk;system unlink`."
        )

    system_name = None
    if len(args) > 0:
        system_name = " ".join(args)

    async with ctx.conn.transaction():
        # TODO: figure out what to do if this errors out on collision on generate_hid
        hid = utils.generate_hid()

        system = await db.create_system(ctx.conn,
                                        system_name=system_name,
                                        system_hid=hid)

        # Link account
        await db.link_account(ctx.conn,
                              system_id=system.id,
                              account_id=ctx.message.author.id)
        return embeds.success(
            "System registered! To begin adding members, use `pk;member new <name>`."
        )
Example #5
0
async def set_log(ctx: CommandContext, args: List[str]):
    if not ctx.message.author.server_permissions.administrator:
        return embeds.error(
            "You must be a server administrator to use this command.")

    server = ctx.message.server
    if len(args) == 0:
        channel_id = None
    else:
        channel = utils.parse_channel_mention(args[0], server=server)
        if not channel:
            return embeds.error("Channel not found.")
        channel_id = channel.id

    await db.update_server(ctx.conn, server.id, logging_channel_id=channel_id)
    return embeds.success("Updated logging channel."
                          if channel_id else "Cleared logging channel.")
Example #6
0
async def switch_out(ctx: MemberCommandContext, args: List[str]):
    # Get current fronters
    fronters, _ = await pluralkit.utils.get_fronter_ids(ctx.conn, system_id=ctx.system.id)
    if not fronters:
        return embeds.error("There's already no one in front.")

    # Log it, and don't log any members
    await db.add_switch(ctx.conn, system_id=ctx.system.id)
    return embeds.success("Switch-out registered.")
Example #7
0
    async def try_proxy_message(self, conn, message: discord.Message):
        # Can't proxy in DMs, webhook creation will explode
        if message.channel.is_private:
            return False

        # Big fat query to find every member associated with this account
        # Returned member object has a few more keys (system tag, for example)
        members = await db.get_members_by_account(conn,
                                                  account_id=message.author.id)

        match = match_proxy_tags(members, message.content)
        if not match:
            return False

        member, text = match
        attachment_url = get_message_attachment_url(message)

        # Can't proxy a message with no text AND no attachment
        if not text and not attachment_url:
            self.logger.debug(
                "Skipping message because of no text and no attachment")
            return False

        try:
            async with conn.transaction():
                await self.do_proxy_message(conn,
                                            member,
                                            message,
                                            text=text,
                                            attachment_url=attachment_url)
        except WebhookPermissionError:
            embed = embeds.error(
                "PluralKit does not have permission to manage webhooks for this channel. Contact your local server administrator to fix this."
            )
            await self.client.send_message(message.channel, embed=embed)
        except DeletionPermissionError:
            embed = embeds.error(
                "PluralKit does not have permission to delete messages in this channel. Contact your local server administrator to fix this."
            )
            await self.client.send_message(message.channel, embed=embed)

        return True
Example #8
0
async def switch_move(ctx: MemberCommandContext, args: List[str]):
    if len(args) == 0:
        return embeds.error("You must pass a time to move the switch to.", help=help.switch_move)

    # Parse the time to move to
    new_time = dateparser.parse(" ".join(args), languages=["en"], settings={
        "TO_TIMEZONE": "UTC",
        "RETURN_AS_TIMEZONE_AWARE": False
    })
    if not new_time:
        return embeds.error("{} can't be parsed as a valid time.".format(" ".join(args)))

    # Make sure the time isn't in the future
    if new_time > datetime.now():
        return embeds.error("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():
        # Get the last two switches to make sure the switch to move isn't before the second-last switch
        last_two_switches = await pluralkit.utils.get_front_history(ctx.conn, ctx.system.id, count=2)
        if len(last_two_switches) == 0:
            return embeds.error("There are no registered switches for this system.")

        last_timestamp, last_fronters = last_two_switches[0]
        if len(last_two_switches) > 1:
            second_last_timestamp, _ = last_two_switches[1]

            if new_time < second_last_timestamp:
                time_str = humanize.naturaltime(second_last_timestamp)
                return embeds.error("Can't move switch to before last switch time ({}), 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(last_timestamp)
        new_absolute = new_time.isoformat(sep=" ", timespec="seconds")
        new_relative = humanize.naturaltime(new_time)
        embed = utils.make_default_embed("This will move the latest switch ({}) from {} ({}) to {} ({}). Is this OK?".format(members, last_absolute, last_relative, new_absolute, new_relative))
        
        # Await and handle confirmation reactions
        confirm_msg = await ctx.reply(embed=embed)
        await ctx.client.add_reaction(confirm_msg, "✅")
        await ctx.client.add_reaction(confirm_msg, "❌")

        reaction = await ctx.client.wait_for_reaction(emoji=["✅", "❌"], message=confirm_msg, user=ctx.message.author, timeout=60.0)
        if not reaction:
            return embeds.error("Switch move timed out.")

        if reaction.reaction.emoji == "❌":
            return embeds.error("Switch move cancelled.")

        # DB requires the actual switch ID which our utility method above doesn't return, do this manually
        switch_id = (await db.front_history(ctx.conn, ctx.system.id, count=1))[0]["id"]

        # Change the switch in the DB
        await db.move_last_switch(ctx.conn, ctx.system.id, switch_id, new_time)
        return embeds.success("Switch moved.")
Example #9
0
async def system_unlink(ctx: CommandContext, args: List[str]):
    # Make sure you can't unlink every account
    linked_accounts = await db.get_linked_accounts(ctx.conn,
                                                   system_id=ctx.system.id)
    if len(linked_accounts) == 1:
        return embeds.error(
            "This is the only account on your system, so you can't unlink it.")

    await db.unlink_account(ctx.conn,
                            system_id=ctx.system.id,
                            account_id=ctx.message.author.id)
    return embeds.success("Account unlinked.")
Example #10
0
async def system_delete(ctx: CommandContext, args: List[str]):
    await ctx.reply(
        "Are you sure you want to delete your system? If so, reply to this message with the system's ID (`{}`)."
        .format(ctx.system.hid))

    msg = await ctx.client.wait_for_message(author=ctx.message.author,
                                            channel=ctx.message.channel,
                                            timeout=60.0)
    if msg and msg.content.lower() == ctx.system.hid.lower():
        await db.remove_system(ctx.conn, system_id=ctx.system.id)
        return embeds.success("System deleted.")
    else:
        return embeds.error("System deletion cancelled.")
Example #11
0
async def system_link(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        return embeds.error("You must pass an account to link this system to.",
                            help=help.link_account)

    # Find account to link
    linkee = await utils.parse_mention(ctx.client, args[0])
    if not linkee:
        return embeds.error("Account not found.")

    # Make sure account doesn't already have a system
    account_system = await db.get_system_by_account(ctx.conn, linkee.id)
    if account_system:
        return embeds.error(
            "The mentioned account is already linked to a system (`{}`)".
            format(account_system.hid))

    # Send confirmation message
    msg = await ctx.reply(
        "{}, please confirm the link by clicking the ✅ reaction on this message."
        .format(linkee.mention))
    await ctx.client.add_reaction(msg, "✅")
    await ctx.client.add_reaction(msg, "❌")

    reaction = await ctx.client.wait_for_reaction(emoji=["✅", "❌"],
                                                  message=msg,
                                                  user=linkee,
                                                  timeout=60.0)
    # If account to be linked confirms...
    if not reaction:
        return embeds.error("Account link timed out.")
    if not reaction.reaction.emoji == "✅":
        return embeds.error("Account link cancelled.")

    await db.link_account(ctx.conn,
                          system_id=ctx.system.id,
                          account_id=linkee.id)
    return embeds.success("Account linked to system.")
Example #12
0
        async def wrapper(ctx: CommandContext, args):
            # Return if no member param
            if len(args) == 0:
                return embeds.error("You must pass a member name or ID.")

            # System is allowed to be none if not system_only
            system_id = ctx.system.id if ctx.system else None
            # And find member by key
            member = await utils.get_member_fuzzy(ctx.conn,
                                                  system_id=system_id,
                                                  key=args[0],
                                                  system_only=system_only)

            if member is None:
                return embeds.error("Can't find member \"{}\".".format(
                    args[0]))

            ctx = MemberCommandContext(client=ctx.client,
                                       conn=ctx.conn,
                                       message=ctx.message,
                                       system=ctx.system,
                                       member=member)
            return await func(ctx, args[1:])
Example #13
0
async def system_info(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        if not ctx.system:
            raise NoSystemRegistered()
        system = ctx.system
    else:
        # Look one up
        system = await utils.get_system_fuzzy(ctx.conn, ctx.client, args[0])

        if system is None:
            return embeds.error("Unable to find system \"{}\".".format(
                args[0]))

    await ctx.reply(embed=await utils.generate_system_info_card(
        ctx.conn, ctx.client, system))
Example #14
0
async def show_help(ctx: CommandContext, args: List[str]):
    embed = utils.make_default_embed("")
    embed.title = "PluralKit Help"
    embed.set_footer(
        text=
        "By Astrid (Ske#6201, or 'qoxvy' on PK) | GitHub: https://github.com/xSke/PluralKit/"
    )

    category = args[0] if len(args) > 0 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
    else:
        return embeds.error("Unknown help page '{}'.".format(category))

    return embed
Example #15
0
async def system_fronthistory(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        if not ctx.system:
            raise NoSystemRegistered()
        system = ctx.system
    else:
        system = await utils.get_system_fuzzy(ctx.conn, ctx.client, args[0])

        if system is None:
            raise embeds.error("Can't find system \"{}\".".format(args[0]))

    lines = []
    front_history = await pluralkit.utils.get_front_history(ctx.conn,
                                                            system.id,
                                                            count=10)
    for i, (timestamp, members) in enumerate(front_history):
        # Special case when no one's fronting
        if len(members) == 0:
            name = "(no fronter)"
        else:
            name = ", ".join([member.name for member in members])

        # Make proper date string
        time_text = timestamp.isoformat(sep=" ", timespec="seconds")
        rel_text = humanize.naturaltime(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))

    embed = utils.make_default_embed("\n".join(lines) or "(none)")
    embed.title = "Past switches"
    return embed
Example #16
0
async def system_set(ctx: CommandContext, args: List[str]):
    if len(args) == 0:
        return embeds.error("You must pass a property name to set.",
                            help=help.edit_system)

    allowed_properties = ["name", "description", "tag", "avatar"]
    db_properties = {
        "name": "name",
        "description": "description",
        "tag": "tag",
        "avatar": "avatar_url"
    }

    prop = args[0]
    if prop not in allowed_properties:
        raise embeds.error(
            "Unknown property {}. Allowed properties are {}.".format(
                prop, ", ".join(allowed_properties)),
            help=help.edit_system)

    if len(args) >= 2:
        value = " ".join(args[1:])
        # Sanity checking
        if prop == "tag":
            if len(value) > 32:
                raise embeds.error(
                    "You can't have a system tag longer than 32 characters.")

            # Make sure there are no members which would make the combined length exceed 32
            members_exceeding = await db.get_members_exceeding(
                ctx.conn, system_id=ctx.system.id, length=32 - len(value) - 1)
            if len(members_exceeding) > 0:
                # If so, error out and warn
                member_names = ", ".join(
                    [member.name for member in members_exceeding])
                logger.debug(
                    "Members exceeding combined length with tag '{}': {}".
                    format(value, member_names))
                raise embeds.error(
                    "The maximum length of a name plus the system tag is 32 characters. The following members would exceed the limit: {}. Please reduce the length of the tag, or rename the members."
                    .format(member_names))

        if prop == "avatar":
            user = await utils.parse_mention(ctx.client, value)
            if user:
                # Set the avatar to the mentioned user's avatar
                # Discord doesn't like webp, but also hosts png alternatives
                value = user.avatar_url.replace(".webp", ".png")
            else:
                # Validate URL
                u = urlparse(value)
                if u.scheme in ["http", "https"] and u.netloc and u.path:
                    value = value
                else:
                    raise embeds.error("Invalid image URL.")
    else:
        # Clear from DB
        value = None

    db_prop = db_properties[prop]
    await db.update_system_field(ctx.conn,
                                 system_id=ctx.system.id,
                                 field=db_prop,
                                 value=value)

    response = embeds.success("{} system {}.".format(
        "Updated" if value else "Cleared", prop))
    if prop == "avatar" and value:
        response.set_image(url=value)
    return response
Example #17
0
 def format(self):
     return "\u274c " + self.text, embeds.error(
         "", self.help) if self.help else None
Example #18
0
async def system_frontpercent(ctx: CommandContext, args: List[str]):
    # Parse the time limit (will go this far back)
    before = dateparser.parse(" ".join(args),
                              languages=["en"],
                              settings={
                                  "TO_TIMEZONE": "UTC",
                                  "RETURN_AS_TIMEZONE_AWARE": False
                              })

    # If time is in the future, just kinda discard
    if before and before > datetime.utcnow():
        before = None

    # Fetch list of switches
    all_switches = await pluralkit.utils.get_front_history(
        ctx.conn, ctx.system.id, 99999)
    if not all_switches:
        return embeds.error("No switches registered to this system.")

    # Cull the switches *ending* before the limit, if given
    # We'll need to find the first switch starting before the limit, then cut off every switch *before* that
    if before:
        for last_stamp, _ in all_switches:
            if last_stamp < before:
                break

        all_switches = [(stamp, members) for stamp, members in all_switches
                        if stamp >= last_stamp]

    start_times = [stamp for stamp, _ in all_switches]
    end_times = [datetime.utcnow()] + start_times
    switch_members = [members for _, members in all_switches]

    # Gonna save a list of members by ID for future lookup too
    members_by_id = {}

    # Using the ID as a key here because it's a simple number that can be hashed and used as a key
    member_times = {}
    for start_time, end_time, members in zip(start_times, end_times,
                                             switch_members):
        # Cut off parts of the switch that occurs before the time limit (will only happen if this is the last switch)
        if before and start_time < before:
            start_time = before

        # Calculate length of the switch
        switch_length = end_time - start_time

        def add_switch(member_id, length):
            if member_id not in member_times:
                member_times[member_id] = length
            else:
                member_times[member_id] += length

        for member in members:
            # Add the switch length to the currently registered time for that member
            add_switch(member.id, switch_length)

            # Also save the member in the ID map for future reference
            members_by_id[member.id] = member

        # Also register a no-fronter switch with the key None
        if not members:
            add_switch(None, switch_length)

    # Find the total timespan of the range
    span_start = max(start_times[-1], before) if before else start_times[-1]
    total_time = datetime.utcnow() - span_start

    embed = embeds.status("")
    for member_id, front_time in sorted(member_times.items(),
                                        key=lambda x: x[1],
                                        reverse=True):
        member = members_by_id[member_id] if member_id else None

        # Calculate percent
        fraction = front_time / total_time
        percent = int(fraction * 100)

        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")))
    return embed
Example #19
0
 def to_embed(self):
     return embeds.error("\u274c " + self.text, self.help)