Merge branch 'staging' into feature-gems

This commit is contained in:
2022-04-02 11:38:47 +03:00
26 changed files with 704 additions and 48 deletions

View File

@@ -1,2 +1,2 @@
CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 10
DATA_VERSION = 11

View File

@@ -1,5 +1,7 @@
from . import data # noqa
from . import patches
from .module import module
from .lion import Lion
from . import blacklists

View File

@@ -11,6 +11,9 @@ meta = RowTable(
attach_as='meta',
)
# TODO: Consider converting to RowTable for per-shard config caching
app_config = Table('AppConfig')
user_config = RowTable(
'user_config',

111
bot/core/patches.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Temporary patches for the discord.py library to support new features of the discord API.
"""
from discord.http import Route, HTTPClient
from discord.abc import Messageable
from discord.utils import InvalidArgument
from discord import File, AllowedMentions
def send_message(self, channel_id, content, *, tts=False, embeds=None,
nonce=None, allowed_mentions=None, message_reference=None):
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
payload = {}
if content:
payload['content'] = content
if tts:
payload['tts'] = True
if embeds:
payload['embeds'] = embeds
if nonce:
payload['nonce'] = nonce
if allowed_mentions:
payload['allowed_mentions'] = allowed_mentions
if message_reference:
payload['message_reference'] = message_reference
return self.request(r, json=payload)
HTTPClient.send_message = send_message
async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=None,
files=None, delete_after=None, nonce=None,
allowed_mentions=None, reference=None,
mention_author=None):
channel = await self._get_channel()
state = self._state
content = str(content) if content is not None else None
if embed is not None:
if embeds is not None:
embeds.append(embed)
else:
embeds = [embed]
embed = embed.to_dict()
if embeds is not None:
embeds = [embed.to_dict() for embed in embeds]
if allowed_mentions is not None:
if state.allowed_mentions is not None:
allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict()
else:
allowed_mentions = allowed_mentions.to_dict()
else:
allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict()
if mention_author is not None:
allowed_mentions = allowed_mentions or AllowedMentions().to_dict()
allowed_mentions['replied_user'] = bool(mention_author)
if reference is not None:
try:
reference = reference.to_message_reference_dict()
except AttributeError:
raise InvalidArgument('reference parameter must be Message or MessageReference') from None
if file is not None and files is not None:
raise InvalidArgument('cannot pass both file and files parameter to send()')
if file is not None:
if not isinstance(file, File):
raise InvalidArgument('file parameter must be File')
try:
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
content=content, tts=tts, embed=embed, nonce=nonce,
message_reference=reference)
finally:
file.close()
elif files is not None:
if len(files) > 10:
raise InvalidArgument('files parameter must be a list of up to 10 elements')
elif not all(isinstance(file, File) for file in files):
raise InvalidArgument('files parameter must be a list of File')
try:
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
embed=embed, nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference)
finally:
for f in files:
f.close()
else:
data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds,
nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference)
ret = state.create_message(channel=channel, data=data)
if delete_after is not None:
await ret.delete(delay=delete_after)
return ret
Messageable.send = send

View File

@@ -4,6 +4,9 @@ from data import tables
import core # noqa
# Note: This MUST be imported after core, due to table definition orders
from settings import AppSettings
import modules # noqa
# Load and attach app specific data
@@ -15,6 +18,8 @@ client.appdata = core.data.meta.fetch_or_create(appname)
client.data = tables
client.settings = AppSettings(conf.bot['data_appid'])
# Initialise all modules
client.initialise_modules()

View File

@@ -13,3 +13,4 @@ from .renting import *
from .moderation import *
from .accountability import *
from .plugins import *
from .sponsors import *

View File

@@ -10,7 +10,7 @@ from .lib import guide_link
new_emoji = " 🆕"
new_commands = {'achievements', 'nerd', 'invite', 'support'}
new_commands = {'botconfig', 'sponsors'}
# Set the command groups to appear in the help
group_hints = {

View File

@@ -0,0 +1,5 @@
from . import module
from . import data
from . import config
from . import commands

View File

@@ -0,0 +1,14 @@
from .module import module
@module.cmd(
name="sponsors",
group="Meta",
desc="Check out our wonderful partners!",
)
async def cmd_sponsors(ctx):
"""
Usage``:
{prefix}sponsors
"""
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))

View File

@@ -0,0 +1,92 @@
from cmdClient.checks import is_owner
from settings import AppSettings, Setting, KeyValueData, ListData
from settings.setting_types import Message, String, GuildIDList
from meta import client
from core.data import app_config
from .data import guild_whitelist
@AppSettings.attach_setting
class sponsor_prompt(String, KeyValueData, Setting):
attr_name = 'sponsor_prompt'
_default = None
write_ward = is_owner
display_name = 'sponsor_prompt'
category = 'Sponsors'
desc = "Text to send after core commands to encourage checking `sponsors`."
long_desc = (
"Text posted after several commands to encourage users to check the `sponsors` command. "
"Occurences of `{{prefix}}` will be replaced by the bot prefix."
)
_quote = False
_table_interface = app_config
_id_column = 'appid'
_key_column = 'key'
_value_column = 'value'
_key = 'sponsor_prompt'
@classmethod
def _data_to_value(cls, id, data, **kwargs):
if data:
return data.replace("{prefix}", client.prefix)
else:
return None
@property
def success_response(self):
if self.value:
return "The sponsor prompt has been update."
else:
return "The sponsor prompt has been cleared."
@AppSettings.attach_setting
class sponsor_message(Message, KeyValueData, Setting):
attr_name = 'sponsor_message'
_default = '{"content": "Coming Soon!"}'
write_ward = is_owner
display_name = 'sponsor_message'
category = 'Sponsors'
desc = "`sponsors` command response."
long_desc = (
"Message to reply with when a user runs the `sponsors` command."
)
_table_interface = app_config
_id_column = 'appid'
_key_column = 'key'
_value_column = 'value'
_key = 'sponsor_message'
_cmd_str = "{prefix}sponsors --edit"
@property
def success_response(self):
return "The `sponsors` command message has been updated."
@AppSettings.attach_setting
class sponsor_guild_whitelist(GuildIDList, ListData, Setting):
attr_name = 'sponsor_guild_whitelist'
write_ward = is_owner
category = 'Sponsors'
display_name = 'sponsor_hidden_in'
desc = "Guilds where the sponsor prompt is not displayed."
long_desc = (
"A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)."
)
_table_interface = guild_whitelist
_id_column = 'appid'
_data_column = 'guildid'
_force_unique = True

View File

@@ -0,0 +1,4 @@
from data import Table
guild_whitelist = Table("sponsor_guild_whitelist")

View File

@@ -0,0 +1,27 @@
import discord
from LionModule import LionModule
from LionContext import LionContext
from meta import client
module = LionModule("Sponsor")
sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
@LionContext.reply.add_wrapper
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
if ctx.cmd and ctx.cmd.name in sponsored_commands:
if (prompt := ctx.client.settings.sponsor_prompt.value):
if not ctx.guild or ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value:
sponsor_hint = discord.Embed(
description=prompt,
colour=discord.Colour.dark_theme()
)
if 'embed' not in kwargs:
kwargs['embed'] = sponsor_hint
return await func(ctx, *args, **kwargs)

View File

@@ -4,3 +4,4 @@ from . import exec_cmds
from . import guild_log
from . import status
from . import blacklist
from . import botconfig

View File

@@ -0,0 +1,96 @@
import difflib
import discord
from cmdClient.checks import is_owner
from settings import UserInputError
from utils.lib import prop_tabulate
from .module import module
@module.cmd("botconfig",
desc="Update global bot configuration.",
flags=('add', 'remove'),
group="Bot Admin")
@is_owner()
async def cmd_botconfig(ctx, flags):
"""
Usage``
{prefix}botconfig
{prefix}botconfig info
{prefix}botconfig <setting>
{prefix}botconfig <setting> <value>
Description:
Usage directly follows the `config` command for guild configuration.
"""
# Cache and map some info for faster access
setting_displaynames = {setting.display_name.lower(): setting for setting in ctx.client.settings.settings.values()}
appid = ctx.client.conf['data_appid']
if not ctx.args or ctx.args.lower() in ('info', 'help'):
# Fill the setting cats
cats = {}
for setting in ctx.client.settings.settings.values():
cat = cats.get(setting.category, [])
cat.append(setting)
cats[setting.category] = cat
# Format the cats
sections = {}
for catname, cat in cats.items():
catprops = {
setting.display_name: setting.get(appid).summary if not ctx.args else setting.desc
for setting in cat
}
# TODO: Add cat description here
sections[catname] = prop_tabulate(*zip(*catprops.items()))
# Build the cat page
embed = discord.Embed(
colour=discord.Colour.orange(),
title="App Configuration"
)
for name, section in sections.items():
embed.add_field(name=name, value=section, inline=False)
await ctx.reply(embed=embed)
else:
# Some args were given
parts = ctx.args.split(maxsplit=1)
name = parts[0]
setting = setting_displaynames.get(name.lower(), None)
if setting is None:
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
match = "`{}`".format('` or `'.join(matches)) if matches else None
return await ctx.error_reply(
"Couldn't find a setting called `{}`!\n"
"{}"
"Use `{}botconfig info` to see all the available settings.".format(
name,
"Maybe you meant {}?\n".format(match) if match else "",
ctx.best_prefix
)
)
if len(parts) == 1 and not ctx.msg.attachments:
# config <setting>
# View config embed for provided setting
await setting.get(appid).widget(ctx, flags=flags)
else:
# config <setting> <value>
# Attempt to set config setting
try:
parsed = await setting.parse(appid, ctx, parts[1] if len(parts) > 1 else '')
parsed.write(add_only=flags['add'], remove_only=flags['remove'])
except UserInputError as e:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', e.msg),
colour=discord.Colour.red()
))
else:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', setting.get(appid).success_response),
colour=discord.Colour.green()
))

View File

@@ -1,4 +1,4 @@
from data.interfaces import RowTable
from data.interfaces import RowTable, Table
topggvotes = RowTable(
'topgg',
@@ -6,3 +6,4 @@ topggvotes = RowTable(
'voteid'
)
guild_whitelist = Table('topgg_guild_whitelist')

View File

@@ -2,6 +2,8 @@ from LionModule import LionModule
from LionContext import LionContext
from core.lion import Lion
from modules.sponsors.module import sponsored_commands
from .utils import get_last_voted_timestamp, lion_loveemote, lion_yayemote
from .webhook import init_webhook
@@ -39,12 +41,16 @@ boostfree_commands = {'config', 'pomodoro'}
async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, **kwargs):
if not suggest_vote:
pass
elif ctx.cmd and (ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups):
elif not ctx.cmd:
pass
elif ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups:
pass
elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value:
pass
elif not get_last_voted_timestamp(ctx.author.id):
upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote)
if 'embed' in kwargs:
if 'embed' in kwargs and ctx.cmd.name not in sponsored_commands:
# Add message as an extra embed field
kwargs['embed'].add_field(
name="\u200b",

View File

@@ -1,10 +1,14 @@
from settings.user_settings import UserSettings, UserSetting
from settings.setting_types import Boolean
from cmdClient.checks import is_owner
from settings import UserSettings, UserSetting, AppSettings
from settings.base import ListData, Setting
from settings.setting_types import Boolean, GuildIDList
from modules.reminders.reminder import Reminder
from modules.reminders.data import reminders
from .utils import create_remainder, remainder_content, topgg_upvote_link
from .data import guild_whitelist
@UserSettings.attach_setting
@@ -48,3 +52,21 @@ class topgg_vote_remainder(Boolean, UserSetting):
return (
"I will no longer send you voting reminders."
)
@AppSettings.attach_setting
class topgg_guild_whitelist(GuildIDList, ListData, Setting):
attr_name = 'topgg_guild_whitelist'
write_ward = is_owner
category = 'Topgg Voting'
display_name = 'topgg_hidden_in'
desc = "Guilds where the topgg vote prompt is not displayed."
long_desc = (
"A list of guilds where the topgg vote prompt will be hidden."
)
_table_interface = guild_whitelist
_id_column = 'appid'
_data_column = 'guildid'
_force_unique = True

View File

@@ -3,3 +3,4 @@ from .setting_types import * # noqa
from .user_settings import UserSettings, UserSetting # noqa
from .guild_settings import GuildSettings, GuildSetting # noqa
from .app_settings import AppSettings

View File

@@ -0,0 +1,5 @@
import settings
from utils.lib import DotDict
class AppSettings(settings.ObjectSettings):
settings = DotDict()

View File

@@ -1,3 +1,4 @@
import json
import discord
from cmdClient.cmdClient import cmdClient
from cmdClient.lib import SafeCancellation
@@ -201,13 +202,13 @@ class Setting:
raise NotImplementedError
@classmethod
async def command(cls, ctx, id):
async def command(cls, ctx, id, flags=()):
"""
Standardised command viewing/setting interface for the setting.
"""
if not ctx.args:
if not ctx.args and not ctx.msg.attachments:
# View config embed for provided cls
await ctx.reply(embed=cls.get(id).embed)
await cls.get(id).widget(ctx, flags=flags)
else:
# Check the write ward
if cls.write_ward and not await cls.write_ward.run(ctx):
@@ -459,5 +460,55 @@ class ListData:
cls._cache[id] = data
class KeyValueData:
"""
Mixin for settings implemented in a Key-Value table.
The underlying table should have a Unique constraint on the `(_id_column, _key_column)` pair.
"""
_table_interface: Table = None
_id_column: str = None
_key_column: str = None
_value_column: str = None
_key: str = None
@classmethod
def _reader(cls, id: ..., **kwargs):
params = {
"select_columns": (cls._value_column, ),
cls._id_column: id,
cls._key_column: cls._key
}
row = cls._table_interface.select_one_where(**params)
data = row[cls._value_column] if row else None
if data is not None:
data = json.loads(data)
return data
@classmethod
def _writer(cls, id: ..., data: ..., **kwargs):
params = {
cls._id_column: id,
cls._key_column: cls._key
}
if data is not None:
values = {
cls._value_column: json.dumps(data)
}
cls._table_interface.upsert(
constraint=f"{cls._id_column}, {cls._key_column}",
**params,
**values
)
else:
cls._table_interface.delete_where(**params)
class UserInputError(SafeCancellation):
pass

View File

@@ -473,6 +473,64 @@ class Emoji(SettingType):
return str(data)
class GuildID(SettingType):
"""
Integer type for storing Guild IDs. Stores any snowflake.
Types:
data: Optional[int]
The stored integer value.
value: Optional[int]
The stored integer value.
"""
accepts = "Any snowflake id."
@classmethod
def _data_from_value(cls, id: int, value: Optional[bool], **kwargs):
"""
Both data and value are of type Optional[int].
Directly return the provided value as data.
"""
return value
@classmethod
def _data_to_value(cls, id: int, data: Optional[bool], **kwargs):
"""
Both data and value are of type Optional[int].
Directly return the internal data as the value.
"""
return data
@classmethod
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
"""
Relies on integer casting to convert the user string
"""
if not userstr or userstr.lower() == "none":
return None
try:
num = int(userstr)
except Exception:
raise UserInputError("Couldn't parse provided guild id.") from None
return num
@classmethod
def _format_data(cls, id: int, data: Optional[int], **kwargs):
"""
Return the string version of the data.
"""
if data is None:
return None
elif (guild := client.get_guild(data)):
return f"`{data}` ({guild.name})"
elif (row := client.data.guild_config.fetch(data)):
return f"`{data}` ({row.name})"
else:
return f"`{data}`"
class Timezone(SettingType):
"""
Timezone type, storing a valid timezone string.
@@ -713,6 +771,8 @@ class Message(SettingType):
_substitution_desc = {
}
_cmd_str = '{prefix} config {setting}'
@classmethod
def _data_from_value(cls, id, value, **kwargs):
if value is None:
@@ -755,12 +815,17 @@ class Message(SettingType):
if as_json:
try:
args = json.loads(userstr)
if not isinstance(args, dict) or (not args.get('content', None) and not args.get('embed', None)):
raise ValueError("At least one of the 'content' or 'embed' data fields are required.")
if not isinstance(args, dict) or (not {'content', 'embed', 'embeds'}.intersection(args.keys())):
raise ValueError("At least one of the 'content', 'embed', or 'embeds' fields are required.")
if 'embed' in args:
discord.Embed.from_dict(
args['embed']
)
if 'embeds' in args:
for embed in args['embeds']:
discord.Embed.from_dict(
embed
)
except Exception as e:
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
raise UserInputError(
@@ -771,6 +836,8 @@ class Message(SettingType):
)
if 'embed' in args and 'timestamp' in args['embed']:
args['embed'].pop('timestamp')
if 'embeds' in args:
[embed.pop('timestamp', None) for embed in args['embeds']]
return json.dumps(args)
else:
return json.dumps({'content': userstr})
@@ -780,9 +847,9 @@ class Message(SettingType):
if data is None:
return "Empty"
value = cls._data_to_value(id, data, **kwargs)
if 'embed' not in value and 'content' not in value:
if not {'embed', 'content', 'embeds'}.intersection(value.keys()):
return "Invalid"
if 'embed' not in value and len(value['content']) < 100:
if 'content' in value and 'embed' not in value and 'embeds' not in value and len(value['content']) < 100:
return "`{}`".format(value['content'])
else:
return "Too long to display here!"
@@ -806,6 +873,13 @@ class Message(SettingType):
args['embed'] = discord.Embed.from_dict(
json.loads(multiple_replace(json.dumps(value['embed']), substitutions))
)
if value.get('embeds', None):
args['embeds'] = [
discord.Embed.from_dict(
json.loads(multiple_replace(json.dumps(embed), substitutions))
)
for embed in value['embeds']
]
return args
async def widget(self, ctx, **kwargs):
@@ -818,7 +892,7 @@ class Message(SettingType):
current_str = None
preview = None
file_content = None
if 'embed' in value or len(value['content']) > 1024:
if 'embed' in value or 'embeds' in value or len(value['content']) > 1024:
current_str = "See attached file."
file_content = json.dumps(value, indent=4)
elif "`" in value['content']:
@@ -844,14 +918,16 @@ class Message(SettingType):
embed.add_field(
name="Setting Guide",
value=(
"• For plain text without an embed, use `{prefix}config {setting} <text>`.\n"
"• For plain text without an embed, use `{cmd_str} <text>`.\n"
"• To include an embed, build the message [here]({builder}) "
"and upload the json code as a file with the `{prefix}config {setting}` command.\n"
"• To reset the message to the default, use `{prefix}config {setting} None`."
"and upload the json code as a file with the `{cmd_str}` command.\n"
"• To reset the message to the default, use `{cmd_str} None`."
).format(
cmd_str=self._cmd_str,
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
).format(
prefix=ctx.best_prefix,
setting=self.display_name,
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
),
inline=False
)
@@ -1028,3 +1104,16 @@ class StringList(SettingList):
"Write `--add` or `--remove` to add or remove strings."
)
_setting = String
class GuildIDList(SettingList):
"""
List of guildids.
"""
accepts = (
"Comma separated list of guild ids. Use `None` to unset. "
"Write `--add` or `--remove` to add or remove ids. "
"The provided ids are not verified in any way."
)
_setting = GuildID