Merge branch 'feature-sponsors' into staging

This commit is contained in:
2022-03-19 17:10:58 +02:00
19 changed files with 495 additions and 72 deletions

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

@@ -1,27 +1,14 @@
from cmdClient.checks import is_owner
from .module import module
from .config import settings
@module.cmd(
name="sponsors",
group="Meta",
desc="Check out our wonderful partners!",
flags=('edit', 'prompt')
)
async def cmd_sponsors(ctx, flags):
async def cmd_sponsors(ctx):
"""
Usage``:
{prefix}sponsors
"""
if await is_owner.run(ctx) and any(flags.values()):
if flags['edit']:
# Run edit setting command
await settings.sponsor_message.command(ctx, 0)
elif flags['prompt']:
# Run prompt setting command
await settings.sponsor_prompt.command(ctx, 0)
else:
# Display message
await ctx.reply(**settings.sponsor_message.args(ctx))
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))

View File

@@ -1,27 +1,22 @@
from cmdClient.checks import is_owner
from settings.base import Setting, ColumnData, ObjectSettings
from settings.setting_types import Message, String
from settings import AppSettings, Setting, KeyValueData, ListData
from settings.setting_types import Message, String, GuildIDList
from meta import client
from utils.lib import DotDict
from core.data import app_config
from .data import sponsor_text
from .data import guild_whitelist
class SponsorSettings(ObjectSettings):
settings = DotDict()
pass
@SponsorSettings.attach_setting
class sponsor_prompt(String, ColumnData, Setting):
@AppSettings.attach_setting
class sponsor_prompt(String, KeyValueData, Setting):
attr_name = 'sponsor_prompt'
_default = "Type {prefix}sponsors to check our wonderful partners!"
_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. "
@@ -30,11 +25,11 @@ class sponsor_prompt(String, ColumnData, Setting):
_quote = False
_data_column = 'prompt_text'
_table_interface = sponsor_text
_id_column = 'ID'
_upsert = True
_create_row = True
_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):
@@ -43,28 +38,55 @@ class sponsor_prompt(String, ColumnData, Setting):
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."
@SponsorSettings.attach_setting
class sponsor_message(Message, ColumnData, Setting):
@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."
)
_data_column = 'command_response'
_table_interface = sponsor_text
_id_column = 'ID'
_upsert = True
_create_row = True
_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."
settings = SponsorSettings(0)
@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

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

View File

@@ -5,8 +5,6 @@ from LionContext import LionContext
from meta import client
from .config import settings
module = LionModule("Sponsor")
@@ -17,11 +15,13 @@ 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:
sponsor_hint = discord.Embed(
description=settings.sponsor_prompt.value,
colour=discord.Colour.dark_theme()
)
if 'embed' not in kwargs:
kwargs['embed'] = sponsor_hint
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

@@ -45,6 +45,8 @@ async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True,
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)

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
@@ -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.
@@ -757,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(
@@ -773,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})
@@ -782,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!"
@@ -808,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):
@@ -820,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']:
@@ -1032,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

View File

@@ -1,8 +1,26 @@
-- App Config Data {{{
CREATE TABLE AppConfig(
appid TEXT,
key TEXT,
value TEXT,
PRIMARY KEY(appid, key)
);
-- }}}
-- Sponsor Data {{{
CREATE TABLE sponsor_text(
ID INTEGER PRIMARY KEY DEFAULT 0,
prompt_text TEXT,
command_response TEXT
CREATE TABLE sponsor_guild_whitelist(
appid TEXT,
guildid BIGINT,
PRIMARY KEY(appid, guildid)
);
-- }}}
-- Topgg Data {{{
CREATE TABLE topgg_guild_whitelist(
appid TEXT,
guildid BIGINT,
PRIMARY KEY(appid, guildid)
);
-- }}}

View File

@@ -22,6 +22,13 @@ CREATE TABLE AppData(
last_study_badge_scan TIMESTAMP
);
CREATE TABLE AppConfig(
appid TEXT,
key TEXT,
value TEXT,
PRIMARY KEY(appid, key)
);
CREATE TABLE global_user_blacklist(
userid BIGINT PRIMARY KEY,
ownerid BIGINT NOT NULL,
@@ -37,16 +44,6 @@ CREATE TABLE global_guild_blacklist(
);
-- }}}
-- Sponsor Data {{{
CREATE TABLE sponsor_text(
ID INTEGER PRIMARY KEY DEFAULT 0,
prompt_text TEXT,
command_response TEXT
);
-- }}}
-- User configuration data {{{
CREATE TABLE user_config(
userid BIGINT PRIMARY KEY,
@@ -808,6 +805,20 @@ create TABLE topgg(
boostedTimestamp TIMESTAMPTZ NOT NULL
);
CREATE INDEX topgg_userid_timestamp ON topgg (userid, boostedTimestamp);
CREATE TABLE topgg_guild_whitelist(
appid TEXT,
guildid BIGINT,
PRIMARY KEY(appid, guildid)
);
-- }}}
-- Sponsor Data {{{
CREATE TABLE sponsor_guild_whitelist(
appid TEXT,
guildid BIGINT,
PRIMARY KEY(appid, guildid)
);
-- }}}
-- vim: set fdm=marker: