Browse Source

Minor refactor

tags/v1.0.0
parent
commit
51f0e986bd
Signed by: StDingenskirchen GPG Key ID: 14FE9712CC42FE8B
17 changed files with 508 additions and 420 deletions
  1. +91
    -75
      blimp/__main__.py
  2. +4
    -4
      blimp/cogs/__init__.py
  3. +25
    -40
      blimp/cogs/alias.py
  4. +19
    -23
      blimp/cogs/board.py
  5. +49
    -47
      blimp/cogs/kiosk.py
  6. +12
    -7
      blimp/cogs/logging.py
  7. +17
    -10
      blimp/cogs/malarkey.py
  8. +23
    -26
      blimp/cogs/moderation.py
  9. +32
    -38
      blimp/cogs/reminders.py
  10. +24
    -13
      blimp/cogs/slowmode.py
  11. +68
    -19
      blimp/cogs/tickets.py
  12. +40
    -19
      blimp/cogs/tools.py
  13. +22
    -15
      blimp/cogs/triggers.py
  14. +47
    -39
      blimp/cogs/welcomelog.py
  15. +29
    -6
      blimp/customizations.py
  16. +4
    -37
      poetry.lock
  17. +2
    -2
      pyproject.toml

+ 91
- 75
blimp/__main__.py View File

@@ -4,12 +4,14 @@ Actually the interesting file, init code lives here

from configparser import ConfigParser
import logging
import re
from string import Template
from typing import Optional

import discord
from discord.ext import commands

from .customizations import Blimp
from .customizations import Blimp, PleaseRestate, AnticipatedError, Unauthorized
from . import cogs


@@ -24,42 +26,64 @@ for source in config["log"]["suppress"].split(","):
lambda row: row.levelno > getattr(logging, config["log"]["level"])
)

intents = discord.Intents.default()
intents.members = True

bot = Blimp(
config,
case_insensitive=True,
activity=Blimp.random_status(),
help_command=None,
intents=intents,
)
for cog in [
cogs.Aliasing,
cogs.Alias,
cogs.Board,
cogs.BotLog,
cogs.LongSlowmode,
cogs.Logging,
cogs.Slowmode,
cogs.Malarkey,
cogs.Moderation,
cogs.Tickets,
cogs.Tools,
cogs.Triggers,
cogs.WelcomeLog,
cogs.RoleKiosk,
cogs.Kiosk,
]:
bot.add_cog(cog(bot))


def process_docstrings(text) -> str:
"Turn a raw function docstring into a help text for display"
return re.sub(
r"(.+)\n *",
r"\1 ",
Template(text).safe_substitute(
{
"manual": bot.config["info"]["manual"],
"sfx": bot.config["discord"]["suffix"],
}
),
)


once_lock = False


@bot.event
async def on_ready():
"""Hello world."""
"Hello world."
bot.log.info(f"Logged in as {bot.user}")

bot.add_cog(cogs.Reminders(bot))
bot.owner_id = (await bot.application_info()).owner.id
global once_lock
if not once_lock:
bot.add_cog(cogs.Reminders(bot))
bot.owner_id = (await bot.application_info()).owner.id

# we can't use fstrings in docstrings, so insert the manual link manually here
post_command = [c for c in bot.commands if c.name[:-1] == "post"][0]
post_command.help = post_command.help.replace(
"MANUALLINKHERE",
f"[extended message formatting]({bot.config['info']['manual']}#extended-post-format)",
)
# inserting runtime data into help
for command in bot.walk_commands():
command.help = process_docstrings(command.help)

once_lock = True


@bot.command(name="help")
@@ -74,66 +98,51 @@ async def _help(ctx: Blimp.Context, *, subject: Optional[str]):
return out

embed = discord.Embed(color=ctx.Color.I_GUESS, title="BLIMP Manual")

if not subject:
embed.description = (
f"This is the *[BLIMP]({ctx.bot.config['info']['web']}) "
"Levitating Intercommunication Management Programme*, a management "
f"bot for Discord.\nFor detailed help, use `{signature(_help)}` "
"with individual commands or any of the larger features "
"listed below. There's also an [online manual]"
f"({ctx.bot.config['info']['manual']}) and, of course, the [source "
f"code]({ctx.bot.config['info']['source']}).\n\n"
embed.description = process_docstrings(
f"""This is the *[BLIMP]({ctx.bot.config['info']['web']}) Levitating Intercommunication
Management Programme*, a general-purpose management bot for Discord.


For detailed help on any command, you can use `{signature(_help)}`. You may also find
useful, but largely supplemental, information in the **[online manual]($manual)**. BLIMP
is [open-source]({ctx.bot.config['info']['source']}). This instance is running on
{len(ctx.bot.guilds)} servers with {len(ctx.bot.users)} members."""
)

for name, cog in sorted( # pylint: disable=redefined-outer-name
ctx.bot.cogs.items(), key=lambda tup: tup[0]
):
all_commands = []
for command in cog.get_commands():
if isinstance(command, commands.Group):
all_commands.extend(
[f"`{command.name} {sub.name}`" for sub in command.commands]
)
all_commands = ""
standalone_commands = ""
previous_group = None
for cmd in sorted(ctx.bot.walk_commands(), key=lambda x: x.qualified_name):
if cmd.__class__ == commands.Command:
if not cmd.parent:
standalone_commands += f"`{cmd.qualified_name}` "
else:
all_commands.append(f"`{command.name}`")

embed.description += (
f"**{name}** "
+ cog.description.split("\n")[0]
+ "\n"
+ " ".join(all_commands)
+ "\n\n"
)
if previous_group != cmd.parent:
all_commands += f"\n**`{cmd.parent.name}`** "
all_commands += f"`{cmd.name}` "

previous_group = cmd.parent

embed.add_field(
name="All Commands", value=standalone_commands + "\n" + all_commands
)

else:
for name, cog in ctx.bot.cogs.items():
if subject.casefold() == name.casefold():
field = ""
for command in cog.get_commands():
field += "\n"
if isinstance(command, commands.Group):
field += "\n".join(
[
f"{signature(sub)}\n{sub.short_doc}"
for sub in command.commands
]
)
else:
field += f"{signature(command)}\n{command.short_doc}\n"
embed.add_field(
name=f"Feature: {name}",
value=cog.description + "\n" + field,
inline=False,
)
for command in ctx.bot.walk_commands():
if subject.casefold() in (
command.qualified_name.casefold(),
command.qualified_name.replace(ctx.bot.suffix, "").casefold(),
):
embed.add_field(
name=f"Command: {signature(command)}",
value=command.help,
inline=False,
)
embed.title = signature(command)
embed.description = command.help

if command.__class__ == commands.Group:
embed.description += "\n\n" + "\n\n".join(
signature(sub) + "\n" + sub.help.split("\n")[0]
for sub in command.commands
)

await ctx.send(None, embed=embed)

@@ -144,25 +153,32 @@ async def on_command_error(ctx, error):
Handle errors, delegating all "internal errors" (exceptions foreign to
discordpy) to stderr and discordpy (i.e. high-level) errors to the user.
"""
if isinstance(error, commands.CommandInvokeError):
ctx.log.error(
f"Encountered exception during executing {ctx.command}", exc_info=error
if isinstance(error, commands.CommandInvokeError) and isinstance(
error.original, AnticipatedError
):
original = error.original
await ctx.reply(
str(original),
title=original.TEXT,
color=ctx.Color.BAD,
delete_after=5.0 if isinstance(original, Unauthorized) else None,
)
return
elif isinstance(error, commands.UserInputError):
await ctx.reply(
"*soon questions arise:*\n"
"*me unwilling, what did you*\n"
"*want in the first place?*",
subtitle="Internal Error.",
str(error),
title=PleaseRestate.TEXT,
color=ctx.Color.BAD,
)
return
elif isinstance(error, commands.CommandNotFound):
return
else:
ctx.log.error(
f"Encountered exception during executing {ctx.command}", exc_info=error
)
await ctx.reply(
"*here's news, good and bad:*\n"
"*the bad, something clearly broke.*\n"
"*the good? not my fault.*",
subtitle=f"Error: {error}",
title="Unable to comply, internal error.",
color=ctx.Color.BAD,
)



+ 4
- 4
blimp/cogs/__init__.py View File

@@ -1,13 +1,13 @@
from discord.ext import commands

from .aliasing import Aliasing
from .alias import Alias
from .board import Board
from .botlog import BotLog
from .longslowmode import LongSlowmode
from .logging import Logging
from .slowmode import Slowmode
from .malarkey import Malarkey
from .moderation import Moderation
from .reminders import Reminders
from .rolekiosk import RoleKiosk
from .kiosk import Kiosk
from .tickets import Tickets
from .tools import Tools
from .triggers import Triggers


blimp/cogs/aliasing.py → blimp/cogs/alias.py View File

@@ -4,30 +4,28 @@ from typing import Union
import discord
from discord.ext import commands

from ..customizations import Blimp
from ..customizations import Blimp, UnableToComply, Unauthorized


class Aliasing(Blimp.Cog):
"""*Giving names to things.*
Aliases allow you to refer to channels and messages (more targets to come)
using simple, server-specific codes like `'rules` or `'the_bread_message`,
that way you don't need to remember unwieldly Discord IDs."""
class Alias(Blimp.Cog):
"Giving names to things."

@staticmethod
def validate_alias(string) -> None:
"""Check if something should be allowed to be an alias."""
if len(string) < 2 or string[0] != "'":
raise commands.UserInputError(
"Aliases must start with ' and have at least one character after that."
raise UnableToComply(
f"Alias {string} does not consist of an apostrophe followed by at least one "
"character."
)
if len([ch for ch in string if ch.isspace()]) > 0:
raise commands.UserInputError("Aliases may not contain whitespace.")
raise UnableToComply(f"Alias {string} contains whitespace.")

@commands.group()
async def alias(self, ctx: Blimp.Context):
"""
Aliases for things so you don't always have to grab their ID
"""
"""Aliases allow you to refer to e.g. messages or channels using simple, server-specific
codes like `'rules` or `'the_bread_message`. This way you don't need to remember unwieldly
Discord IDs like `526166150749618178`."""

@commands.command(parent=alias)
async def make(
@@ -37,11 +35,15 @@ class Aliasing(Blimp.Cog):
alias: str,
):
"""
Create an alias for a Discord object (messages, channels, categories).
Aliases must start with a single ', have no whitespace, and be unique.
Create an alias to refer to a Discord object.

`target` may be a message, a text channel, or a category.

`alias` must start with an apostrophe `'` and contain no whitespace, but is otherwise
entirely your choice.
"""
if not ctx.privileged_modify(ctx.guild):
return
raise Unauthorized()

self.validate_alias(alias)

@@ -64,14 +66,7 @@ class Aliasing(Blimp.Cog):
)
except sqlite3.DatabaseError:
ctx.database.execute("ABORT;")
await ctx.reply(
"*that word seems common*\n"
"*for it's an alias.*\n"
"*no doubles allowed.*",
subtitle=f"{alias} is already registered as an alias.",
color=ctx.Color.I_GUESS,
)
return
raise UnableToComply(f"Alias {alias} is already registered.")

ctx.database.execute("COMMIT;")

@@ -80,23 +75,18 @@ class Aliasing(Blimp.Cog):

@commands.command(parent=alias)
async def delete(self, ctx: Blimp.Context, alias: str):
"Delete an alias, freeing it up for renewed use."
"""Delete an alias, freeing it up for renewed use.

`alias` must have been registered as an alias before."""

if not ctx.privileged_modify(ctx.guild):
return
raise Unauthorized()

self.validate_alias(alias)

old = ctx.objects.by_alias(ctx.guild.id, alias)
if not old:
await ctx.reply(
"*commonly you ask*\n"
"*to delete extant objects*\n"
"*though not this time.*",
subject="Unknown alias.",
color=ctx.Color.I_GUESS,
)
return
raise UnableToComply(f"Alias {alias} doesn't exist.")

ctx.database.execute(
"DELETE FROM aliases WHERE gid=:gid AND alias=:alias",
@@ -110,6 +100,7 @@ class Aliasing(Blimp.Cog):
@commands.command(parent=alias, name="list")
async def _list(self, ctx: Blimp.Context):
"List all aliases currently configured for this server."

cursor = ctx.database.execute(
"SELECT * FROM aliases WHERE gid=:gid", {"gid": ctx.guild.id}
)
@@ -121,13 +112,7 @@ class Aliasing(Blimp.Cog):
[f"{d[0]}: {await ctx.bot.represent_object(d[1])}" for d in data]
)
if not result:
await ctx.reply(
"*honest yet verbose,*\n"
"*no aliases 'round here.*\n"
"*maybe you'll change that?*",
subtitle="No aliases configured for this server.",
color=ctx.Color.I_GUESS,
)
await ctx.reply("*There are no aliases configured in this server.*")
return

await ctx.reply(result)

+ 19
- 23
blimp/cogs/board.py View File

@@ -5,19 +5,17 @@ import re
import discord
from discord.ext import commands

from ..customizations import Blimp
from .aliasing import MaybeAliasedTextChannel
from ..customizations import Blimp, UnableToComply, Unauthorized
from .alias import MaybeAliasedTextChannel


class Board(Blimp.Cog):
"""*Building monuments to all your sins.*
A Board is a channel that gets any messages that get enough of certain
reactions reposted into it. Also known as "starboard" on other, merely
land-bound, bots."""
"Building monuments to all your sins."

@commands.group()
async def board(self, ctx: Blimp.Context):
"Configure boards in this server."
"""A Board is a channel that gets any messages that get enough of certain reactions reposted
into it. Also known as "starboard" on other, sadly land-bound, bots."""

@commands.command(parent=board)
async def update(
@@ -29,15 +27,18 @@ class Board(Blimp.Cog):
post_age_limit: bool = False,
):
"""Update a Board, overwriting prior configuration.
`emoji` may be any custom or built-in emoji or a literal "any",
in which case the repost will trigger for any emoji.
`min_reacts` is how many emoji you want the messages to have before
they get reposted.
`post_age_limit` controls if posts older than the board configuration
should be reposted."""

`emoji` is the reaction the Board listens to, it may be either a custom or built-in emoji,
or "any". In the latter case, any emoji can trigger the Board.

`min_reacts` is how many reactions of `emoji` you want a message to have before it triggers
the Board.

`post_age_limit` controls if posts older than the board configuration should be reposted, by
default old posts are ignored."""

if not ctx.privileged_modify(channel.guild):
return
raise Unauthorized()

emoji_id = re.search(r"(\d{10,})>?$", emoji)
if emoji_id:
@@ -90,24 +91,19 @@ class Board(Blimp.Cog):

@commands.command(parent=board)
async def disable(self, ctx: Blimp.Context, channel: MaybeAliasedTextChannel):
"Disable a Board, deleting prior configuration."
"Disable a Board and delete its configuration."

if not ctx.privileged_modify(channel.guild):
return
raise Unauthorized()

cursor = ctx.database.execute(
"""DELETE FROM board_configuration WHERE oid=:oid""",
{"oid": ctx.objects.by_data(tc=channel.id)},
)
if cursor.rowcount == 0:
await ctx.reply(
"*although unthought yet,*\n"
"*more frivolous than a Board*\n"
"*may be its absence.*",
subtitle="No Board is configured in that channel.",
color=ctx.Color.I_GUESS,
raise UnableToComply(
f"Can't disable Board in {channel.mention} as none exists."
)
return

await ctx.bot.post_log(
channel.guild, f"{ctx.author} deleted board {channel.mention}."


blimp/cogs/rolekiosk.py → blimp/cogs/kiosk.py View File

@@ -4,21 +4,19 @@ from typing import List, Union

import discord
from discord.ext import commands
from discord.ext.commands import UserInputError

from ..customizations import Blimp
from .aliasing import MaybeAliasedMessage
from .alias import MaybeAliasedMessage


class RoleKiosk(Blimp.Cog):
"""*Handing out fancy badges.*
Role Kiosks allow you to have members assign roles to themselves
by reacting to a message with emoji. To modify role kiosks, you both
need to be able to manage the server and all roles you want to offer."""
class Kiosk(Blimp.Cog):
"Handing out fancy badges."

@commands.group()
async def kiosk(self, ctx: Blimp.Context):
"Manage your server's role kiosks."
"""Kiosks allow users to pick roles by reacting to specific messages with certain reactions.
This is frequently used for pronouns, ping roles, opt-ins or colors. Every Kiosk has its own
set of reaction-role pairings."""

@staticmethod
def parse_emoji_pairs(args: List[Union[discord.Role, str]]):
@@ -45,10 +43,10 @@ class RoleKiosk(Blimp.Cog):
):
"""
Update a role kiosk, overwriting its setup entirely.
Target doesn't have to be a kiosk prior to issuing this command.

[args] means: :emoji1: @Role1 :emoji2: @Role2 :emojiN: @RoleN
Up to 20 pairs per message, due to Discord limitations.
`args` is a space-separated list of one emoji each followed by one role. This determines the
options the kiosk will have available. Due to Discord limitations, only 20 pairs are
possible per message.
"""

if not ctx.privileged_modify(msg.guild):
@@ -57,9 +55,18 @@ class RoleKiosk(Blimp.Cog):
result = self.parse_emoji_pairs(args)

if len(result) == 0:
raise UserInputError("Expected arguments :emoji: role :emoji: role...")
await ctx.reply(
"**Please restate query:** No valid reaction-role pairings found.",
color=ctx.Color.BAD,
)
return

if len(result) > 20:
raise UserInputError("Can't use more than 20 reactions per kiosk.")
await ctx.reply(
"**Unable to comply:** Can't use more than 20 reaction-role pairs per message.",
color=ctx.Color.BAD,
)
return

user_failed_roles = []
bot_failed_roles = []
@@ -71,22 +78,16 @@ class RoleKiosk(Blimp.Cog):

if user_failed_roles:
await ctx.reply(
"*how promethean,*\n"
"*gifting roles you don't control.*"
"*yet I must decline.*",
subtitle="You can't manage these roles: "
+ " ".join([r.name for r in user_failed_roles]),
"**Unauthorized:** You can't modify the following roles: "
+ " ".join([r.mention for r in user_failed_roles]),
color=ctx.Color.BAD,
)
return

if bot_failed_roles:
await ctx.reply(
"*despite best efforts,*\n"
"*this kiosk is doomed to fail,*\n"
"*its roles beyond me.*",
subtitle="The bot can't manage these roles: "
+ " ".join([r.name for r in bot_failed_roles]),
"**Unable to comply:** BLIMP can't modify the following roles: "
+ " ".join([r.mention for r in bot_failed_roles]),
color=ctx.Color.BAD,
)
return
@@ -151,10 +152,11 @@ class RoleKiosk(Blimp.Cog):
args: commands.Greedy[Union[discord.Role, str]],
):
"""
Update a kiosk, appending options to it. Prior configuration will remain intact.
Update a kiosk, appending options to it instead of overwriting.

[args] means: :emoji1: @Role1 :emoji2: @Role2 :emojiN: @RoleN
Up to 20 pairs per message, due to Discord limitations.
`args` is a space-separated list of one emoji each followed by one role. This determines the
options the kiosk will have available. Due to Discord limitations, only 20 pairs are
possible per message.
"""

if not ctx.privileged_modify(msg.guild):
@@ -167,11 +169,10 @@ class RoleKiosk(Blimp.Cog):

if not old:
await ctx.reply(
"*quick, concatenate!*\n"
"*you demand. nought to add to*\n"
"*ah, I feel distressed*",
"**Unable to comply:** Message isn't a kiosk yet. Create one using `kiosk"
+ ctx.bot.config["discord"]["suffix"]
+ " update` first.",
color=ctx.Color.BAD,
subtitle="This kiosk doesn't exist yet. Use kiosk! update to create one.",
)
return

@@ -180,9 +181,18 @@ class RoleKiosk(Blimp.Cog):
result = self.parse_emoji_pairs(args)

if len(result + old_data) == 0:
raise UserInputError("Expected arguments :emoji: role :emoji: role...")
await ctx.reply(
"**Please restate query:** No valid reaction-role pairings found.",
color=ctx.Color.BAD,
)
return

if len(result + old_data) > 20:
raise UserInputError("Can't use more than 20 reactions per kiosk.")
await ctx.reply(
"**Unable to comply:** Can't use more than 20 reaction-role pairs per message.",
color=ctx.Color.BAD,
)
return

user_failed_roles = []
bot_failed_roles = []
@@ -194,22 +204,16 @@ class RoleKiosk(Blimp.Cog):

if user_failed_roles:
await ctx.reply(
"*how promethean,*\n"
"*gifting roles you don't control.*"
"*yet I must decline.*",
subtitle="You can't manage these roles: "
+ " ".join([r.name for r in user_failed_roles]),
"**Unauthorized:** You can't modify the following roles: "
+ " ".join([r.mention for r in user_failed_roles]),
color=ctx.Color.BAD,
)
return

if bot_failed_roles:
await ctx.reply(
"*despite best efforts,*\n"
"*this kiosk is doomed to fail,*\n"
"*its roles beyond me.*",
subtitle="The bot can't manage these roles: "
+ " ".join([r.name for r in bot_failed_roles]),
"**Unable to comply:** BLIMP can't modify the following roles: "
+ " ".join([r.mention for r in bot_failed_roles]),
color=ctx.Color.BAD,
)
return
@@ -275,12 +279,10 @@ class RoleKiosk(Blimp.Cog):
)
if cursor.rowcount == 0:
await ctx.reply(
"*trying to comply*\n"
"*I searched all the kiosks known*\n"
"*that one's still foreign*",
subtitle="That message isn't a role kiosk.",
color=ctx.Color.I_GUESS,
"**Unable to comply:** Can't delete kiosk as it doesn't exist.",
color=ctx.Color.BAD,
)
return

for emoji in [item for item in msg.reactions if item.me]:
await msg.remove_reaction(emoji.emoji, ctx.guild.me)

blimp/cogs/botlog.py → blimp/cogs/logging.py View File

@@ -1,22 +1,27 @@
from discord.ext import commands

from ..customizations import Blimp
from .aliasing import MaybeAliasedTextChannel
from ..customizations import Blimp, Unauthorized
from .alias import MaybeAliasedTextChannel


class BotLog(Blimp.Cog):
"""*Watching with ten thousand eyes.*
Set up logging for your server to keep you informed on BLIMP actions."""
class Logging(Blimp.Cog):
"Watching with ten thousand eyes."

@commands.group()
async def logging(self, ctx: Blimp.Context):
"Configure logging"
"""Set up logging for your server to keep you informed on BLIMP's actions.

Currently, logs are generated by **configuration changes** for logging, Boards, Kiosks,
Slowmodes, Tickets, Triggers, and the Welcome and Goodbye logs. Additional logs are
generated for individual **ticket creation and deletion**, as well as ticket **add/remove
events**. **Channel bans** and unbans also are logged, as are changes to **channel
names/topics** made through BLIMP."""

@commands.command(parent=logging, name="set")
async def _set(self, ctx: Blimp.Context, channel: MaybeAliasedTextChannel):
"Set the logging channel."
if not ctx.privileged_modify(channel.guild):
return
raise Unauthorized()

await ctx.bot.post_log(
channel.guild,

+ 17
- 10
blimp/cogs/malarkey.py View File

@@ -9,8 +9,7 @@ from ..eff_large_wordlist import WORDS


class Malarkey(Blimp.Cog):
"""*Silly things.*
And you know why? Because life itself is filled with no reason."""
"And you know why? Because life itself is filled with no reason."

pings = ["Pong!", "Ping!", "DING!", "Pyongyang!", "PLONK", "Pink!"]

@@ -27,12 +26,14 @@ class Malarkey(Blimp.Cog):
pong = random.choice(self.pings)
in_delta = now - ctx.message.created_at
msg = await ctx.reply(
f"*{pong}*", subtitle=f"Inbound: {in_delta/timedelta(milliseconds=1)}ms",
f"*{pong}*",
subtitle=f"Inbound: {in_delta/timedelta(milliseconds=1)}ms",
)
out_delta = msg.created_at - now
await msg.edit(
embed=discord.Embed(
color=ctx.Color.GOOD, description=f"*{pong}*",
color=ctx.Color.GOOD,
description=f"*{pong}*",
).set_footer(
text=f"Inbound: {in_delta/timedelta(milliseconds=1)}ms | "
f"Outbound: {out_delta/timedelta(milliseconds=1)}ms"
@@ -41,17 +42,23 @@ class Malarkey(Blimp.Cog):

@commands.command()
async def givemeafreegenderneutralname(self, ctx: Blimp.Context):
"""
Generates gender-neutral names (or codenames, if you're feeling secretive),
words courtesy of the Electronic Frontier Foundation (CC-BY 3.0)
"""
"""Get yourself a free and gender-neutral name.
Words courtesy of the EFF."""
amount = random.randint(2, 3)
words = random.choices(WORDS, k=amount)

await ctx.reply(f"{ctx.author.mention} is now known as **{' '.join(words)}**.")
await ctx.reply(
"**I AUTHORISE AND REQUIRE** all persons at all times to designate, "
f"describe, and address {ctx.author.mention} by the adopted free and gender-neutral "
f"name of **{' '.join(words)}**."
)

@commands.command()
async def choose(self, ctx: Blimp.Context, *options):
"Choose a random option of those provided"
"""Choose a random option of those provided.

`options` is a space-separated list of options."""

await ctx.reply(f"Hmmm… I choose {random.choice(options)}!")

+ 23
- 26
blimp/cogs/moderation.py View File

@@ -3,13 +3,12 @@ from typing import Optional
import discord
from discord.ext import commands

from ..customizations import Blimp
from .aliasing import MaybeAliasedTextChannel
from ..customizations import Blimp, Unauthorized, UnableToComply
from .alias import MaybeAliasedTextChannel


class Moderation(Blimp.Cog):
"""*Suppressing your free speech since 1724.*
Tools for server mods."""
"*Suppressing your free speech since 1724.*"

@commands.command()
async def channelban(
@@ -20,22 +19,22 @@ class Moderation(Blimp.Cog):
*,
reason: str,
):
"Ban a member from writing in a channel. They'll still be able to read."
"""Ban a member from writing in a channel. They'll still be able to read.

`channel` is the channel to ban from. If left empty, BLIMP works with the current channel.

`member` is the member to channel-ban.

`reason` is the reason for the ban. It's mandatory."""

if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return
raise Unauthorized()

if member == ctx.author:
await ctx.reply(
"*I cannot comply*\n"
"*mustn't harm the operator*\n"
"*go blame Asimov*",
subtitle="You can't channelban yourself.",
color=ctx.Color.BAD,
)
return
raise UnableToComply("You can't channelban yourself.")

ctx.database.execute(
"INSERT INTO channelban_entries(channel_oid, guild_oid, user_oid, issuer_oid, reason) "
@@ -57,7 +56,7 @@ class Moderation(Blimp.Cog):
channel.guild,
f"{ctx.author} channel-banned {member.mention} from {channel.mention}:\n> {reason}",
)
await ctx.reply(f"Channel-banned {member.mention}.")
await ctx.reply(f"*Channel-banned {member.mention}.*")

@commands.command()
async def unchannelban(
@@ -66,22 +65,20 @@ class Moderation(Blimp.Cog):
channel: Optional[MaybeAliasedTextChannel],
member: discord.Member,
):
"Lift a channelban from a member."
"""Lift a channelban from a member.

`channel` is the channel to unban from. If left empty, BLIMP works with the current channel.

`member` is the member to lift the channel-ban from."""

if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return

if member == ctx.author:
await ctx.reply(
"*blade of grass, broken*\n"
"*from own growth now asks for help*\n"
"*too bad, had your chance*",
subtitle="You can't unchannelban yourself.",
color=ctx.Color.BAD,
)
return
if member == ctx.author and not ctx.privileged_modify(channel.guild):
raise Unauthorized("You can't lift a channelban on yourself.")

ctx.database.execute(
"DELETE FROM channelban_entries WHERE channel_oid=:c_oid AND user_oid=:u_oid",
@@ -97,7 +94,7 @@ class Moderation(Blimp.Cog):
channel.guild,
f"{ctx.author} lifted the channelban on {member.mention} in {channel.mention}",
)
await ctx.reply(f"Lifted the channel-ban on {member.mention}.")
await ctx.reply(f"*Lifted the channel-ban on {member.mention}.*")

@Blimp.Cog.listener()
async def on_member_join(self, member: discord.Member):


+ 32
- 38
blimp/cogs/reminders.py View File

@@ -5,14 +5,16 @@ from typing import Union, Optional
import discord
from discord.ext import commands, tasks

from ..customizations import Blimp, ParseableDatetime, ParseableTimedelta
from ..customizations import (
Blimp,
ParseableDatetime,
ParseableTimedelta,
UnableToComply,
)


class Reminders(Blimp.Cog):
"""*Reminding you of things that you believe are going to happen.*
Reminders allow you to set notifications for a future self. When the time
comes, BLIMP will ping you with a text you set and a link to the original
message."""
"Reminding you of things that you believe are going to happen."

def __init__(self, *args):
self.execute_reminders.start() # pylint: disable=no-member
@@ -74,25 +76,23 @@ class Reminders(Blimp.Cog):
)

@commands.group()
async def reminder(self, ctx: Blimp.Context):
"Manage timed reminders."
async def reminders(self, ctx: Blimp.Context):
"""Reminders allow you to set notifications for a future self. When the time comes, BLIMP
will ping you with a text you set and a link to the original message.

@commands.command(parent=reminder, name="list")
**You can create reminders using `remindme$sfx`**"""

@commands.command(parent=reminders, name="list")
async def _list(self, ctx: Blimp.Context):
"List all pending reminders for you."
"List all pending reminders for you in DMs."

rems = ctx.database.execute(
"SELECT * FROM reminders_entries WHERE user_id=:user_id",
{"user_id": ctx.author.id},
).fetchall()

if not rems:
await ctx.reply(
"*here? nothing. bleak, yet*\n"
"*lacking future's burdens*\n"
"*maybe you are free.*",
subtitle="You have no pending reminders.",
color=ctx.Color.I_GUESS,
)
await ctx.reply("You have no pending reminders.", color=ctx.Color.I_GUESS)
return

rows = []
@@ -104,25 +104,22 @@ class Reminders(Blimp.Cog):
delta = delta - timedelta(microseconds=delta.microseconds)
rows.append(f"#{rem['id']} **{delta} ({invoke_link})**\n{rem['text']}")

await ctx.reply("\n".join(rows))
await Blimp.Context.reply(ctx.author, "\n".join(rows))

@commands.command(parent=reminder)
@commands.command(parent=reminders)
async def delete(self, ctx: Blimp.Context, number: int):
"Delete one of your reminders."
"""Delete one of your reminders.

`number` is the number listed first in a `reminders$sfx list` row."""

old = ctx.database.execute(
"SELECT * FROM reminders_entries WHERE user_id=:user_id AND id=:id",
{"user_id": ctx.author.id, "id": number},
).fetchone()
if not old:
await ctx.reply(
"*set to erase one*\n"
"*I poured through all records*\n"
"*yet the hunt proved fruitless.*",
subtitle="Error: Unknown reminder ID.",
color=ctx.Color.I_GUESS,
raise UnableToComply(
f"Can't delete your reminder #{number} as it doesn't exist."
)
return

ctx.database.execute(
"DELETE FROM reminders_entries WHERE user_id=:user_id AND id=:id",
@@ -141,10 +138,14 @@ class Reminders(Blimp.Cog):
):
"""Add a timed reminder for yourself.

`when` may be a timestamp in ISO 8601 format ("YYYY-MM-DD HH:MM:SS") or
a delta from now, like "90 days" or 1h or "1 minute 30secs".
You will be reminded either in this channel or via DM if the channel is
no longer reachable."""
`when` can be either a [duration]($manual#arguments) from now or a [time
stamp]($manual#arguments). Either way, it determines when the reminder will fire.

`text` is your reminder text. You can leave this empty.

You will be reminded either in the channel where the command was issued or via DM if that
channel is no longer reachable."""

due = None
if isinstance(when, datetime):
due = when.replace(microsecond=0)
@@ -161,14 +162,7 @@ class Reminders(Blimp.Cog):
text = "[no reminder text provided]"

if due < datetime.now(timezone.utc):
await ctx.reply(
"*tempting as it seems*\n"
"*it's best not to venture there*\n"
"*let the past lie dead.*",
subtitle="You can't set reminders for past events.",
color=ctx.Color.I_GUESS,
)
return
raise UnableToComply("You can't set reminders for past events.")

cursor = ctx.database.execute(
"""INSERT INTO reminders_entries(user_id, message_oid, due, text)


blimp/cogs/longslowmode.py → blimp/cogs/slowmode.py View File

@@ -4,17 +4,18 @@ from typing import Optional
import discord
from discord.ext import commands

from ..customizations import Blimp, ParseableTimedelta
from .aliasing import MaybeAliasedTextChannel
from ..customizations import Blimp, ParseableTimedelta, Unauthorized
from .alias import MaybeAliasedTextChannel


class LongSlowmode(Blimp.Cog):
"""*Deleting things that are just too new for your taste.*
Manages slowmode enforcement for arbitrary durations."""
class Slowmode(Blimp.Cog):
"Deleting things that are just too new for your taste."

@commands.group()
async def slowmode(self, ctx: Blimp.Context):
"Manage slowmodes."
"""BLIMP Slowmode is an extension of Discord's built-in slowmode, with arbitrary length for
the slowmode. Once set up in a channel, BLIMP monitors all message timestamps and deletes
messages that were posted to recently, notifying the user in question via DM."""

@commands.command(parent=slowmode, name="set")
async def _set(
@@ -25,15 +26,20 @@ class LongSlowmode(Blimp.Cog):
ignore_mods: bool = True,
):
"""Set a channel's slowmode to an arbitrary value.
If the duration is over 6 hours, the bot will manually enforce slowmode
by deleting messages that have been posted too soon since the last one.
By default, ignores moderators in that channel.
Set duration to 0 to disable."""

`channel` is the channel to target, if left empty, BLIMP works with the current channel.

`duration` is a [duration]($manual#arguments). If over 6 hours, the bot will manually
enforce slowmode by deleting messages that have been posted too soon since the last one. Set
to a zero duration to disable slowmode.

`ignore_mods` determines if BLIMP will delete messages from mods too. By default, it won't.
"""
if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return
raise Unauthorized()

secs = duration.total_seconds()
await channel.edit(slowmode_delay=min(21600, secs), reason=str(ctx.author))
@@ -66,12 +72,17 @@ class LongSlowmode(Blimp.Cog):
channel: Optional[MaybeAliasedTextChannel],
user: discord.Member,
):
"Reset a user's slowmode data, so they can post again immediately."
"""Reset a user's slowmode data, so they can post again immediately.

`channel` is the channel to target, if left empty, BLIMP works with the current channel.

`user` is the server member whose timestamp should be reset."""

if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return
raise Unauthorized()

ctx.database.execute(
"DELETE FROM slowmode_entries WHERE channel_oid=:channel_oid AND user_oid=:user_oid",

+ 68
- 19
blimp/cogs/tickets.py View File

@@ -6,20 +6,24 @@ import discord
from discord.ext import commands
import toml

from .aliasing import MaybeAliasedCategoryChannel, MaybeAliasedTextChannel
from .alias import (
MaybeAliasedCategoryChannel,
MaybeAliasedTextChannel,
Unauthorized,
)
from ..customizations import Blimp
from ..transcript import Transcript
from ..message_formatter import create_message_dict


class Tickets(Blimp.Cog):
"""*Honorary citizen #23687, please! Don't push.*
Tickets allow users to create temporary channels to, for example, request assistance, talk to
moderators privately, or organize internal discussions."""
"Honorary citizen #23687, please! Don't push."

@commands.group()
async def ticket(self, ctx: Blimp.Context):
"Manage individual tickets and overall ticket configuration."
"""Tickets are temporary private channels created by a user to, for example, request
assistance, talk to moderators privately, or organize internal discussions. They are
automatically archived into an easily-readable format on deletion."""

@commands.command(parent=ticket)
async def updatecategory(
@@ -31,10 +35,24 @@ class Tickets(Blimp.Cog):
can_creator_close: bool,
per_user_limit: Optional[int],
):
"Update a Ticket category, overwriting its setup entirely."
"""Update a Ticket category, overwriting its setup entirely.

`category` is the channel category to edit. New Tickets are created inside of it.

`last_ticket_number` is the number of the last ticket. If you're creating a new category,
you probably want to set this to `0`.

`transcript_channel` is the channel where BLIMP will post transcripts of deleted Tickets.
These include a full conversation transcript rendered as HTML and some statistics.

`can_creator_close` determines if the creator of a ticket should be allowed to delete it.
Depending on your use case, this may not be desirable.

`per_user_limit` is an optional maximum number of Tickets a single unprivileged user can
have in this category. If they exceed it, BLIMP will refuse to open further Tickets."""

if not ctx.privileged_modify(category.guild):
return
raise Unauthorized()

log_embed = discord.Embed(
description=f"{ctx.author} updated ticket category {category.name}",
@@ -95,10 +113,17 @@ class Tickets(Blimp.Cog):
*,
description: str,
):
"Update a Ticket class, overwriting its setup entirely."
"""Update a Ticket class, overwriting its setup entirely.

`category` is the channel category whose Ticket Classes you want to edit.

`name` is both the identifier of this class and the inital prefix of ticket channels.

`description` is text that gets automatically posted into a new ticket in this category.
[Advanced Message Formatting]($manual#advanced-message-formatting) is available."""

if not ctx.privileged_modify(category.guild):
return
raise Unauthorized()

log_embed = discord.Embed(
description=f"{ctx.author} updated ticket class {category.name}/{name}",
@@ -147,9 +172,12 @@ class Tickets(Blimp.Cog):
category: MaybeAliasedCategoryChannel,
ticket_class: Optional[str],
):
"""Open a new ticket in the specified category.
"""Open a new ticket.

`category` is the channel category to open a ticket in.

[ticket_class] can be left out if only one class exists in that category."""
`ticket_class` is the class the new ticket should have. If the category only has one, this
can be left out."""

with ctx.database as trans:
ticket_category = trans.execute(
@@ -187,7 +215,10 @@ class Tickets(Blimp.Cog):
},
).fetchone()
if count[0] >= ticket_category["per_user_limit"]:
return
raise Unauthorized(
f"You can only open {ticket_category['per_user_limit']} tickets in this"
"category at once."
)

ticket_channel = await category.create_text_channel(
f"{actual_class['name']}-{(ticket_category['count'] + 1)}",
@@ -270,7 +301,9 @@ class Tickets(Blimp.Cog):
ctx: Blimp.Context,
channel: Optional[MaybeAliasedTextChannel],
):
"""Delete a ticket and post a transcript."""
"""Delete a ticket and create a transcript.

`channel` is the ticket to delete. If left empty, BLIMP works with the current channel."""

if not channel:
channel = ctx.channel
@@ -291,7 +324,11 @@ class Tickets(Blimp.Cog):
ctx.privileged_modify(channel)
or (category["can_creator_close"] and ctx.author.id == ticket["creator_id"])
):
return
raise Unauthorized(
"Only Staff "
+ ("and the ticket owner " if category["can_creator_close"] else "")
+ "can close this ticket."
)

await ctx.reply("Saving transcript…")
transcript_channel_obj = ctx.objects.by_oid(category["transcript_channel_oid"])
@@ -321,7 +358,7 @@ class Tickets(Blimp.Cog):
messages = await Transcript.write_transcript(temp, channel)
temp.seek(0)

participants = set([message.author for message in messages])
participants = {message.author for message in messages}
archive_embed.add_field(
name="Participants",
value="\n".join(user.mention for user in participants),
@@ -363,7 +400,13 @@ class Tickets(Blimp.Cog):
channel: Optional[MaybeAliasedTextChannel],
members: commands.Greedy[discord.Member],
):
"Add members to a ticket."
"""Add members to a ticket.

`channel` is the ticket to add members to. If left empty, BLIMP works with the current
channel.

`members` is a list of members that you want to add."""

if not channel:
channel = ctx.channel

@@ -377,7 +420,7 @@ class Tickets(Blimp.Cog):
if not (
ctx.privileged_modify(channel) or ctx.author.id == ticket["creator_id"]
):
return
raise Unauthorized("Only Staff and the ticket owner can add members.")

member_text = " ".join([member.mention for member in members])
await ctx.bot.post_log(
@@ -411,7 +454,13 @@ class Tickets(Blimp.Cog):
channel: Optional[MaybeAliasedTextChannel],
members: commands.Greedy[discord.Member],
):
"Remove members from a ticket."
"""Remove members from a ticket.

`channel` is the ticket to remove members from. If left empty, BLIMP works with the current
channel.

`members` is a list of members that you want to remove."""

if not channel:
channel = ctx.channel

@@ -425,7 +474,7 @@ class Tickets(Blimp.Cog):
if not (
ctx.privileged_modify(channel) or ctx.author.id == ticket["creator_id"]
):
return
raise Unauthorized("Only Staff and the ticket owner can remove members.")

member_text = " ".join([member.mention for member in members])
await ctx.bot.post_log(


+ 40
- 19
blimp/cogs/tools.py View File

@@ -5,8 +5,8 @@ import discord
from discord.ext import commands
import toml

from ..customizations import Blimp, ParseableTimedelta
from .aliasing import (
from ..customizations import Blimp, ParseableTimedelta, Unauthorized
from .alias import (
MaybeAliasedCategoryChannel,
MaybeAliasedTextChannel,
MaybeAliasedMessage,
@@ -15,19 +15,18 @@ from ..message_formatter import create_message_dict


class Tools(Blimp.Cog):
"""*Semi-useful things, actually.*
This is a collection of commands that relate to everyday management
and aren't significant enough to warrant their own module."""
"Semi-useful things, actually."

@commands.command()
async def cleanup(self, ctx: Blimp.Context, limit: int = 20, any_bot: bool = False):
"""Go through the last messages and delete bot responses.

`limit` controls the amount of messages searched, the default is 20.
If `any_bot` is provided, will clear messages by any bot and not
just BLIMP's."""

`any_bot` determines if only BLIMP's (the default) or all bots' messages are cleared."""

if not ctx.privileged_modify(ctx.channel):
return
raise Unauthorized()

async with ctx.typing():
purged = await ctx.channel.purge(
@@ -50,7 +49,13 @@ class Tools(Blimp.Cog):
category: MaybeAliasedCategoryChannel,
duration: ParseableTimedelta = ParseableTimedelta(days=2),
):
"List channels in a category that have been stale for a certain duration."
"""List channels haven't been for a certain duration.

`category` is the channel category that should be inspected.

`duration` is a [duration]($manual#arguments). Channels that haven't received any messages
during this time are considered stale and will be printed. Defaults to two days."""

channels = []
for channel in category.channels:
if (
@@ -77,7 +82,8 @@ class Tools(Blimp.Cog):
@commands.command()
@commands.is_owner()
async def eval(self, ctx: Blimp.Context, *, code: str):
"Evaluate an expression as a lambda."
"Parse an expression as a lambda and apply it to the Context. No, you can't use this."

the_letter_after_kappa = eval(code) # pylint: disable=eval-used
await the_letter_after_kappa(ctx)
await ctx.message.add_reaction("\N{WHITE HEAVY CHECK MARK}")
@@ -86,7 +92,10 @@ class Tools(Blimp.Cog):
async def pleasetellmehowmanypeoplehave(
self, ctx: Blimp.Context, role: discord.Role
):
"Show how many members have a certain role."
"""Show how many members have a certain role.

`role` is the role whose members should be counted."""

members = [m for m in ctx.guild.members if role in m.roles]
await ctx.reply(f"{len(members)} members have {role.mention}.")

@@ -98,12 +107,17 @@ class Tools(Blimp.Cog):
*,
text: str,
):
"Set the description of a channel."
"""Set the description of a channel.

`channel` is the channel to edit. If left empty, BLIMP works with the current channel.

`text` is the new description of the channel. Standard Discord formatting works."""

if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return
raise Unauthorized()

log_embed = (
discord.Embed(
@@ -127,12 +141,17 @@ class Tools(Blimp.Cog):
*,
text: str,
):
"Set the name of a channel."
"""Set the name of a channel.

`channel` is the channel to edit. If left empty, BLIMP works with the current channel.

`text` is the new name of the channel."""

if not channel:
channel = ctx.channel

if not ctx.privileged_modify(channel):
return
raise Unauthorized()

log_embed = (
discord.Embed(
@@ -156,13 +175,15 @@ class Tools(Blimp.Cog):
*,
text: str,
):
"""Make the bot post something.
"""Make BLIMP post something on your behalf.

`where` is either a channel to post in, or a previous `post$sfx`-created message to edit.

<where> may be a channel or a message created by this command to update.
<text> may be either plain text for the message content or TOML data for MANUALLINKHERE."""
`text` is the new content of the message. [Advanced Message
Formatting]($manual#advanced-message-formatting) is available."""

if not ctx.privileged_modify(where.guild):
return
raise Unauthorized()

try:
text = toml.dumps(toml.loads(text))


+ 22
- 15
blimp/cogs/triggers.py View File

@@ -3,17 +3,18 @@ from datetime import datetime, timezone
import discord
from discord.ext import commands

from ..customizations import Blimp
from .aliasing import MaybeAliasedMessage
from ..customizations import Blimp, Unauthorized, UnableToComply
from .alias import MaybeAliasedMessage


class Triggers(Blimp.Cog):
"""*The Big Red Button.*
Triggers allow your users to invoke pre-set commands by reacting to a specific message."""
"The Big Red Button."

@commands.group()
async def trigger(self, ctx: Blimp.Context):
"Manage your server's command triggers."
"""Triggers allow your users to invoke pre-set commands by reacting to a specific message.
BLIMP uses this to allow easy ticket deletion. The possibilities, however, are limitless.
Commands are always ran as the user that reacts to the post."""

@commands.command(parent=trigger)
async def update(
@@ -21,10 +22,15 @@ class Triggers(Blimp.Cog):
):
"""
Update a trigger, overwriting its setup entirely.
"""

`msg` is the message a trigger should be created/edited on.

`emoji` is the reaction the trigger should be sensitive to.

`command` is the command to execute when the reaction is used."""

if not ctx.privileged_modify(msg.guild):
return
raise Unauthorized()

log_embed = discord.Embed(
description=f"{ctx.author} updated "
@@ -71,10 +77,14 @@ class Triggers(Blimp.Cog):

@commands.command(parent=trigger)
async def delete(self, ctx: Blimp.Context, msg: MaybeAliasedMessage, emoji: str):
"Delete a trigger (but not the message)."
"""Delete a trigger, but not the message.

`msg` is the message to delete a trigger on.

`emoji` is the reaction BLIMP should no longer consider a trigger."""

if not ctx.privileged_modify(msg.guild):
return
raise Unauthorized()

cursor = ctx.database.execute(
"DELETE FROM trigger_entries WHERE message_oid=:message_oid AND emoji=:emoji",
@@ -84,12 +94,9 @@ class Triggers(Blimp.Cog):
},
)
if cursor.rowcount == 0:
await ctx.reply(
"*trying to comply*\n"
"*I searched all the kiosks known*\n"
"*that one's still foreign*",
subtitle="That message isn't a role kiosk.",
color=ctx.Color.I_GUESS,
raise UnableToComply(
f"Can't delete trigger [trigger {emoji} in #{msg.channel.name}]({msg.jump_url}) "
"as it doesn't exist."
)

await msg.remove_reaction(emoji, ctx.guild.me)


+ 47
- 39
blimp/cogs/welcomelog.py View File

@@ -5,20 +5,13 @@ import discord
from discord.ext import commands
import toml

from ..customizations import Blimp
from .aliasing import MaybeAliasedTextChannel
from ..customizations import Blimp, Unauthorized, UnableToComply
from .alias import MaybeAliasedTextChannel
from ..message_formatter import create_message_dict


class WelcomeLog(Blimp.Cog):
"""*Greeting and goodbye-ing people.*
Welcome and Goodbye allow you to greet and see off users that join/leave
your server. The messages allow you to mention the user in question, but
don't offer a lot of detail a proper logging bot would provide, mostly
because that's a different use case.
Inside greeting texts, the following variables are available: `$user`
mentions the member, `$id` is their ID, `$tag` is their DiscordTag#1234,
and `$avatar` is their avatar."""
"Greeting and goodbye-ing people."

@staticmethod
def member_variables(member: discord.Member) -> dict:
@@ -32,7 +25,15 @@ class WelcomeLog(Blimp.Cog):

@commands.group()
async def welcome(self, ctx: Blimp.Context):
"Configure user-facing join notifications."
"""Welcome allows you to greet users that join your server. The automated greeting is highly
flexible, but probably unsuitable for security purposes. For that you probably want a
dedicated logging bot!

Inside greeting texts, the following **variables** are available: `$user` mentions the
member, `$id` is their ID, `$tag` is their DiscordTag#1234, and `$avatar` is their avatar.
[Advanced Message Formatting]($manual#advanced-message-formatting) is available in
greetings.
"""

@commands.command(parent=welcome, name="update")
async def w_update(
@@ -40,10 +41,13 @@ class WelcomeLog(Blimp.Cog):
):
"""Update user-facing join messages for this server.

`channel` designates where the messages will be posted.
In `greeting`, $user is replaced with a mention of the user joining."""
`channel` is the channel where join greetings will be posted.

`greeting` is the text of the greeting messages. [Advanced Message
Formatting]($manual#advanced-message-formatting) is available."""

if not ctx.privileged_modify(channel.guild):
return
raise Unauthorized()

logging_embed = discord.Embed(
description=f"{ctx.author} updated Welcome.", color=ctx.Color.I_GUESS
@@ -86,22 +90,19 @@ class WelcomeLog(Blimp.Cog):

@commands.command(parent=welcome, name="disable")
async def w_disable(self, ctx: Blimp.Context):
"Disable the server's welcome messages and delete stored data."
"Disable the server's welcome greetings and delete the configuration."

if not ctx.privileged_modify(ctx.guild):
return
raise Unauthorized()

cursor = ctx.database.execute(
"UPDATE welcome_configuration SET join_data=NULL WHERE oid=:oid",
{"oid": ctx.objects.make_object(g=ctx.guild.id)},
)
if cursor.rowcount == 0:
await ctx.reply(
"""*I, yet again tasked*
*to erase what doesn't exist,*
*quietly ignore.*""",
subtitle="Welcome is not enabled for this server.",
color=ctx.Color.I_GUESS,
raise UnableToComply(
"Can't delete Welcome configuration as it doesn't exist."
)
return

await self.bot.post_log(ctx.guild, f"{ctx.author} disabled Welcome.")

@@ -109,9 +110,8 @@ class WelcomeLog(Blimp.Cog):

@Blimp.Cog.listener()
async def on_member_join(self, member: discord.Member):
"""
Look up if we have a configuration for this guild and greet if so.
"""
"Look up if we have a configuration for this guild and greet if so."

objects = self.bot.objects

cursor = self.bot.database.execute(
@@ -133,7 +133,15 @@ class WelcomeLog(Blimp.Cog):

@commands.group()
async def goodbye(self, ctx: Blimp.Context):
"Configure user-facing leave notifications."
"""Goodbye allows you to see off users that leave your server. The automated goodbye is
highly flexible, but probably unsuitable for security purposes. For that you probably want a
dedicated logging bot!

Inside goodbye messages, the following **variables** are available: `$user` mentions the
member, `$id` is their ID, `$tag` is their DiscordTag#1234, and `$avatar` is their avatar.
[Advanced Message Formatting]($manual#advanced-message-formatting) is available in
goodbye messages.
"""

@commands.command(parent=goodbye, name="update")
async def g_update(
@@ -141,10 +149,13 @@ class WelcomeLog(Blimp.Cog):
):
"""Update user-facing leave messages for this server.

`channel` designates where the messages will be posted.
In `greeting`, $user is replaced with a mention of the user leaving."""
`channel` is the channel where goodbye messages will be posted.

`greeting` is the text of the goodbye messages. [Advanced Message
Formatting]($manual#advanced-message-formatting) is available."""

if not ctx.privileged_modify(channel.guild):
return
raise Unauthorized()

logging_embed = discord.Embed(
description=f"{ctx.author} updated Goodbye.", color=ctx.Color.I_GUESS
@@ -187,22 +198,19 @@ class WelcomeLog(Blimp.Cog):

@commands.command(parent=goodbye, name="disable")
async def g_disable(self, ctx: Blimp.Context):
"Disable the server's goodbye messages and delete stored data."
"Disable the server's goodbye messages and delete the configuration."

if not ctx.privileged_modify(ctx.guild):
return
raise Unauthorized()

cursor = ctx.database.execute(
"UPDATE welcome_configuration SET leave_data=NULL WHERE oid=:oid",
{"oid": ctx.objects.make_object(g=ctx.guild.id)},
)
if cursor.rowcount == 0:
await ctx.reply(
"""*heartbroken, ordered*
*to remove a farewell, joy*
*as I can refuse.*""",
subtitle="Goodbye is not enabled for this server.",
color=ctx.Color.I_GUESS,
raise UnableToComply(
"Can't delete Goodbye configuration as it doesn't exist."
)
return

await self.bot.post_log(ctx.guild, f"{ctx.author} disabled Goodbye.")



+ 29
- 6
blimp/customizations.py View File

@@ -13,6 +13,25 @@ from discord.ext import commands
from .objects import BlimpObjects


class AnticipatedError(Exception):
"An error we expected."


class UnableToComply(AnticipatedError):
"We understood what the user wants, but can't."
TEXT = "Unable to comply."


class Unauthorized(AnticipatedError):
"We understood what the user wants, but they aren't allowed to do it."
TEXT = "Unauthorized."


class PleaseRestate(AnticipatedError):
"We didn't understand what the user wants."
TEXT = "Please restate query."


class Blimp(commands.Bot):
"""
Instead of using a prefix like... normal bots, Blimp checks if the first
@@ -56,9 +75,11 @@ class Blimp(commands.Bot):
async def reply(
self,
msg: str = None,
title: str = discord.Embed.Empty,
subtitle: str = None,
color: Color = Color.GOOD,
embed: discord.Embed = None,
delete_after: float = None,
):
"""Helper for sending embedded replies"""
if not embed:
@@ -72,8 +93,9 @@ class Blimp(commands.Bot):
await self.send(
"",
embed=discord.Embed(
color=color, description=buf
color=color, description=buf, title=title
).set_footer(text=subtitle),
delete_after=delete_after,
)
buf = ""
else:
@@ -82,12 +104,13 @@ class Blimp(commands.Bot):
if len(buf) > 0:
return await self.send(
"",
embed=discord.Embed(color=color, description=buf).set_footer(
text=subtitle
),
embed=discord.Embed(
color=color, description=buf, title=title
).set_footer(text=subtitle),
delete_after=delete_after,
)

return await self.send("", embed=embed)
return await self.send("", embed=embed, delete_after=delete_after)

def privileged_modify(
self,
@@ -102,7 +125,7 @@ class Blimp(commands.Bot):
return True

kind = subject.__class__
if kind == discord.TextChannel or kind == discord.CategoryChannel:
if kind in (discord.TextChannel, discord.CategoryChannel):
return self.author.permissions_in(subject).manage_messages
if kind == discord.Member:
return self.author.guild_permissions.ban_users


+ 4
- 37
poetry.lock View File

@@ -116,11 +116,10 @@ description = "A Python wrapper for the Discord API"
name = "discord.py"
optional = false
python-versions = ">=3.5.3"
version = "1.3.4"
version = "1.5.0"

[package.dependencies]
aiohttp = ">=3.6.0,<3.7.0"
websockets = ">=6.0,<7.0 || >7.0,<8.0 || >8.0,<8.0.1 || >8.0.1,<9.0"

[package.extras]
docs = ["sphinx (1.8.5)", "sphinxcontrib-trio (1.1.1)", "sphinxcontrib-websupport"]
@@ -242,14 +241,6 @@ optional = false
python-versions = "*"
version = "3.7.4.3"

[[package]]
category = "main"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
name = "websockets"
optional = false
python-versions = ">=3.6.1"
version = "8.1"

[[package]]
category = "dev"
description = "Module for decorators, wrappers and monkey patching."
@@ -275,7 +266,7 @@ python = "<3.8"
version = ">=3.7.4"

[metadata]
content-hash = "8a0fa15f87d5ffac1332de194cc1959b5cb39dcdfd3e04b079374e55d38b46af"
content-hash = "b740cd1addf6cc8362d324d57c5657c71de34dc6acbc72179bd3930f80b6acf3"
python-versions = "^3.7"

[metadata.files]
@@ -325,8 +316,8 @@ colorama = [
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
]
"discord.py" = [
{file = "discord.py-1.3.4-py3-none-any.whl", hash = "sha256:8ef58d6fc1e66903bc00ae79c4c09a38aa71043e88a83da4d2e8b9b1c9f9b9e2"},
{file = "discord.py-1.3.4.tar.gz", hash = "sha256:1b546a32c0cd83d949392a71e5b06e30e19d1067246e3826d32ae9b8b3d06c1e"},
{file = "discord.py-1.5.0-py3-none-any.whl", hash = "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211"},
{file = "discord.py-1.5.0.tar.gz", hash = "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
@@ -453,30 +444,6 @@ typing-extensions = [
{file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
]
websockets = [
{file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"},
{file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"},
{file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"},
{file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"},
{file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"},
{file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"},
{file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"},
{file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"},
{file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"},
{file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"},
{file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"},
{file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"},
{file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"},
{file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"},
{file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"},
{file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"},
{file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"},
{file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"},
{file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"},
{file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"},
{file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"},
{file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"},
]
wrapt = [
{file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"},
]


+ 2
- 2
pyproject.toml View File

@@ -1,13 +1,13 @@
[tool.poetry]
name = "blimp"
version = "0.1.0"
version = "1.0.0"
description = "The BLIMP Levitating Intercommunication Management Programme, a Discord bot."
authors = ["Cassidy Dingenskirchen <admin@15318.de>"]
license = "AGPL-3.0"

[tool.poetry.dependencies]
python = "^3.7"
"discord.py" = "~1.3"
"discord.py" = "~1.5"
toml = "^0.10.1"

[tool.poetry.dev-dependencies]


Loading…
Cancel
Save