Added guild and user settings module.
This commit is contained in:
5
bot/settings/__init__.py
Normal file
5
bot/settings/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .base import * # noqa
|
||||||
|
from .setting_types import * # noqa
|
||||||
|
|
||||||
|
from .user_settings import UserSettings, UserSetting # noqa
|
||||||
|
from .guild_settings import GuildSettings, GuildSetting # noqa
|
||||||
431
bot/settings/base.py
Normal file
431
bot/settings/base.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import discord
|
||||||
|
from cmdClient.cmdClient import cmdClient, Context
|
||||||
|
from cmdClient.lib import SafeCancellation
|
||||||
|
from cmdClient.Check import Check
|
||||||
|
|
||||||
|
from utils.lib import prop_tabulate, DotDict
|
||||||
|
|
||||||
|
from meta import client
|
||||||
|
from data import Table, RowTable
|
||||||
|
|
||||||
|
|
||||||
|
class Setting:
|
||||||
|
"""
|
||||||
|
Abstract base class describing a stored configuration setting.
|
||||||
|
A setting consists of logic to load the setting from storage,
|
||||||
|
present it in a readable form, understand user entered values,
|
||||||
|
and write it again in storage.
|
||||||
|
Additionally, the setting has attributes attached describing
|
||||||
|
the setting in a user-friendly manner for display purposes.
|
||||||
|
"""
|
||||||
|
attr_name: str = None # Internal attribute name for the setting
|
||||||
|
_default: ... = None # Default data value for the setting.. this may be None if the setting overrides 'default'.
|
||||||
|
|
||||||
|
write_ward: Check = None # Check that must be passed to write the setting. Not implemented internally.
|
||||||
|
|
||||||
|
# Configuration interface descriptions
|
||||||
|
display_name: str = None # User readable name of the setting
|
||||||
|
desc: str = None # User readable brief description of the setting
|
||||||
|
long_desc: str = None # User readable long description of the setting
|
||||||
|
accepts: str = None # User readable description of the acceptable values
|
||||||
|
|
||||||
|
def __init__(self, id, data: ..., **kwargs):
|
||||||
|
self.client: cmdClient = client
|
||||||
|
self.id = id
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
# Configuration embeds
|
||||||
|
@property
|
||||||
|
def embed(self):
|
||||||
|
"""
|
||||||
|
Discord Embed showing an information summary about the setting.
|
||||||
|
"""
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Configuration options for `{}`".format(self.display_name),
|
||||||
|
)
|
||||||
|
fields = ("Current value", "Default value", "Accepted input")
|
||||||
|
values = (self.formatted or "Not Set",
|
||||||
|
self._format_data(self.id, self.default) or "None",
|
||||||
|
self.accepts)
|
||||||
|
table = prop_tabulate(fields, values)
|
||||||
|
embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary(self):
|
||||||
|
"""
|
||||||
|
Formatted summary of the data.
|
||||||
|
May be implemented in `_format_data(..., summary=True, ...)` or overidden.
|
||||||
|
"""
|
||||||
|
return self._format_data(self.id, self.data, summary=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
"""
|
||||||
|
Response message sent when the setting has successfully been updated.
|
||||||
|
"""
|
||||||
|
return "Setting Updated!"
|
||||||
|
|
||||||
|
# Instance generation
|
||||||
|
@classmethod
|
||||||
|
def get(cls, id: int, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a setting instance initialised from the stored value.
|
||||||
|
"""
|
||||||
|
data = cls._reader(id, **kwargs)
|
||||||
|
return cls(id, data, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a setting instance initialised from a parsed user string.
|
||||||
|
"""
|
||||||
|
data = await cls._parse_userstr(ctx, id, userstr, **kwargs)
|
||||||
|
return cls(id, data, **kwargs)
|
||||||
|
|
||||||
|
# Main interface
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
"""
|
||||||
|
Retrieves the current internal setting data if it is set, otherwise the default data
|
||||||
|
"""
|
||||||
|
return self._data if self._data is not None else self.default
|
||||||
|
|
||||||
|
@data.setter
|
||||||
|
def data(self, new_data):
|
||||||
|
"""
|
||||||
|
Sets the internal setting data and writes the changes.
|
||||||
|
"""
|
||||||
|
self._data = new_data
|
||||||
|
self.write()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default(self):
|
||||||
|
"""
|
||||||
|
Retrieves the default value for this setting.
|
||||||
|
Settings should override this if the default depends on the object id.
|
||||||
|
"""
|
||||||
|
return self._default
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
"""
|
||||||
|
Discord-aware object or objects associated with the setting.
|
||||||
|
"""
|
||||||
|
return self._data_to_value(self.id, self.data)
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, new_value):
|
||||||
|
"""
|
||||||
|
Setter which reads the discord-aware object, converts it to data, and writes it.
|
||||||
|
"""
|
||||||
|
self._data = self._data_from_value(self.id, new_value)
|
||||||
|
self.write()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted(self):
|
||||||
|
"""
|
||||||
|
User-readable form of the setting.
|
||||||
|
"""
|
||||||
|
return self._format_data(self.id, self.data)
|
||||||
|
|
||||||
|
def write(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Write value to the database.
|
||||||
|
For settings which override this,
|
||||||
|
ensure you handle deletion of values when internal data is None.
|
||||||
|
"""
|
||||||
|
self._writer(self.id, self._data, **kwargs)
|
||||||
|
|
||||||
|
# Raw converters
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value, **kwargs):
|
||||||
|
"""
|
||||||
|
Convert a high-level setting value to internal data.
|
||||||
|
Must be overriden by the setting.
|
||||||
|
Be aware of None values, these should always pass through as None
|
||||||
|
to provide an unsetting interface.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Convert internal data to high-level setting value.
|
||||||
|
Must be overriden by the setting.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Parse user provided input into internal data.
|
||||||
|
Must be overriden by the setting if the setting is user-configurable.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Convert internal data into a formatted user-readable string.
|
||||||
|
Must be overriden by the setting if the setting is user-viewable.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# Database access classmethods
|
||||||
|
@classmethod
|
||||||
|
def _reader(cls, id: int, **kwargs):
|
||||||
|
"""
|
||||||
|
Read a setting from storage and return setting data or None.
|
||||||
|
Must be overriden by the setting.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _writer(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Write provided setting data to storage.
|
||||||
|
Must be overriden by the setting unless the `write` method is overidden.
|
||||||
|
If the data is None, the setting is empty and should be unset.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def command(cls, ctx, id):
|
||||||
|
"""
|
||||||
|
Standardised command viewing/setting interface for the setting.
|
||||||
|
"""
|
||||||
|
if not ctx.args:
|
||||||
|
# View config embed for provided cls
|
||||||
|
await ctx.reply(embed=cls.get(id).embed)
|
||||||
|
else:
|
||||||
|
# Check the write ward
|
||||||
|
if cls.write_ward and not await cls.write_ward.run(ctx):
|
||||||
|
await ctx.error_reply(cls.write_ward.msg)
|
||||||
|
else:
|
||||||
|
# Attempt to set config cls
|
||||||
|
try:
|
||||||
|
cls = await cls.parse(id, ctx, ctx.args)
|
||||||
|
except UserInputError as e:
|
||||||
|
await ctx.reply(embed=discord.Embed(
|
||||||
|
description="{} {}".format('❌', e.msg),
|
||||||
|
Colour=discord.Colour.red()
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
cls.write()
|
||||||
|
await ctx.reply(embed=discord.Embed(
|
||||||
|
description="{} {}".format('✅', cls.success_response),
|
||||||
|
Colour=discord.Colour.green()
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectSettings:
|
||||||
|
"""
|
||||||
|
Abstract class representing a linked collection of settings for a single object.
|
||||||
|
Initialised settings are provided as instance attributes in the form of properties.
|
||||||
|
"""
|
||||||
|
__slots__ = ('id', 'params')
|
||||||
|
|
||||||
|
settings: DotDict = None
|
||||||
|
|
||||||
|
def __init__(self, id, **kwargs):
|
||||||
|
self.id = id
|
||||||
|
self.params = tuple(kwargs.items())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _setting_property(cls, setting):
|
||||||
|
def wrapped_setting(self):
|
||||||
|
return setting.get(self.id, **dict(self.params))
|
||||||
|
return wrapped_setting
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def attach_setting(cls, setting: Setting):
|
||||||
|
name = setting.attr_name or setting.__name__
|
||||||
|
setattr(cls, name, property(cls._setting_property(setting)))
|
||||||
|
cls.settings[name] = setting
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
class ColumnData:
|
||||||
|
"""
|
||||||
|
Mixin for settings stored in a single row and column of a Table.
|
||||||
|
Intended to be used with tables where the only primary key is the object id.
|
||||||
|
"""
|
||||||
|
# Table storing the desired data
|
||||||
|
_table_interface: Table = None
|
||||||
|
|
||||||
|
# Name of the column storing the setting object id
|
||||||
|
_id_column: str = None
|
||||||
|
|
||||||
|
# Name of the column with the desired data
|
||||||
|
_data_column: str = None
|
||||||
|
|
||||||
|
# Whether to use create a row if not found (only applies to TableRow)
|
||||||
|
_create_row = False
|
||||||
|
|
||||||
|
# Whether to upsert or update for updates
|
||||||
|
_upsert: bool = True
|
||||||
|
|
||||||
|
# High level data cache to use, set to None to disable cache.
|
||||||
|
_cache = None # Map[id -> value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reader(cls, id: int, use_cache=True, **kwargs):
|
||||||
|
"""
|
||||||
|
Read in the requested entry associated to the id.
|
||||||
|
Supports reading cached values from a `RowTable`.
|
||||||
|
"""
|
||||||
|
if cls._cache is not None and id in cls._cache and use_cache:
|
||||||
|
return cls._cache[id]
|
||||||
|
|
||||||
|
table = cls._table_interface
|
||||||
|
if isinstance(table, RowTable) and cls._id_column == table.id_col:
|
||||||
|
if cls._create_row:
|
||||||
|
row = table.fetch_or_create(id)
|
||||||
|
else:
|
||||||
|
row = table.fetch(id)
|
||||||
|
data = row.data[cls._data_column] if row else None
|
||||||
|
else:
|
||||||
|
params = {
|
||||||
|
"select_columns": (cls._data_column,),
|
||||||
|
cls._id_column: id
|
||||||
|
}
|
||||||
|
row = table.select_one_where(**params)
|
||||||
|
data = row[cls._data_column] if row else None
|
||||||
|
|
||||||
|
if cls._cache is not None:
|
||||||
|
cls._cache[id] = data
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _writer(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Write the provided entry to the table, allowing replacements.
|
||||||
|
"""
|
||||||
|
table = cls._table_interface
|
||||||
|
params = {
|
||||||
|
cls._id_column: id
|
||||||
|
}
|
||||||
|
values = {
|
||||||
|
cls._data_column: data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update data
|
||||||
|
if cls._upsert:
|
||||||
|
# Upsert data
|
||||||
|
table.upsert(
|
||||||
|
constraint=cls._id_column,
|
||||||
|
**params,
|
||||||
|
**values
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Update data
|
||||||
|
table.update_where(values, **params)
|
||||||
|
|
||||||
|
if cls._cache is not None:
|
||||||
|
cls._cache[id] = data
|
||||||
|
|
||||||
|
|
||||||
|
class ListData:
|
||||||
|
"""
|
||||||
|
Mixin for list types implemented on a Table.
|
||||||
|
Implements a reader and writer.
|
||||||
|
This assumes the list is the only data stored in the table,
|
||||||
|
and removes list entries by deleting rows.
|
||||||
|
"""
|
||||||
|
# Table storing the setting data
|
||||||
|
_table_interface: Table = None
|
||||||
|
|
||||||
|
# Name of the column storing the id
|
||||||
|
_id_column: str = None
|
||||||
|
|
||||||
|
# Name of the column storing the data to read
|
||||||
|
_data_column: str = None
|
||||||
|
|
||||||
|
# Name of column storing the order index to use, if any. Assumed to be Serial on writing.
|
||||||
|
_order_column: str = None
|
||||||
|
_order_type: str = "ASC"
|
||||||
|
|
||||||
|
# High level data cache to use, set to None to disable cache.
|
||||||
|
_cache = None # Map[id -> value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reader(cls, id: int, use_cache=True, **kwargs):
|
||||||
|
"""
|
||||||
|
Read in all entries associated to the given id.
|
||||||
|
"""
|
||||||
|
if cls._cache is not None and id in cls._cache and use_cache:
|
||||||
|
return cls._cache[id]
|
||||||
|
|
||||||
|
table = cls._table_interface # type: Table
|
||||||
|
params = {
|
||||||
|
"select_columns": [cls._data_column],
|
||||||
|
cls._id_column: id
|
||||||
|
}
|
||||||
|
if cls._order_column:
|
||||||
|
params['_extra'] = "ORDER BY {} {}".format(cls._order_column, cls._order_type)
|
||||||
|
|
||||||
|
rows = table.select_where(**params)
|
||||||
|
data = [row[cls._data_column] for row in rows]
|
||||||
|
|
||||||
|
if cls._cache is not None:
|
||||||
|
cls._cache[id] = data
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _writer(cls, id: int, data: ..., add_only=False, remove_only=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Write the provided list to storage.
|
||||||
|
"""
|
||||||
|
# TODO: Transaction lock on the table so this is atomic
|
||||||
|
# Or just use the connection context manager
|
||||||
|
|
||||||
|
table = cls._table_interface # type: Table
|
||||||
|
|
||||||
|
# Handle None input as an empty list
|
||||||
|
if data is None:
|
||||||
|
data = []
|
||||||
|
|
||||||
|
current = cls._reader(id, **kwargs)
|
||||||
|
if not cls._order_column and (add_only or remove_only):
|
||||||
|
to_insert = [item for item in data if item not in current] if not remove_only else []
|
||||||
|
to_remove = data if remove_only else (
|
||||||
|
[item for item in current if item not in data] if not add_only else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle required deletions
|
||||||
|
if to_remove:
|
||||||
|
params = {
|
||||||
|
cls._id_column: id,
|
||||||
|
cls._data_column: to_remove
|
||||||
|
}
|
||||||
|
table.delete_where(**params)
|
||||||
|
|
||||||
|
# Handle required insertions
|
||||||
|
if to_insert:
|
||||||
|
columns = (cls._id_column, cls._data_column)
|
||||||
|
values = [(id, value) for value in to_insert]
|
||||||
|
table.insert_many(*values, insert_keys=columns)
|
||||||
|
|
||||||
|
if cls._cache is not None:
|
||||||
|
new_current = [item for item in current + to_insert if item not in to_remove]
|
||||||
|
cls._cache[id] = new_current
|
||||||
|
else:
|
||||||
|
# Remove all and add all to preserve order
|
||||||
|
# TODO: This really really should be atomic if anything else reads this
|
||||||
|
delete_params = {cls._id_column: id}
|
||||||
|
table.delete_where(**delete_params)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
columns = (cls._id_column, cls._data_column)
|
||||||
|
values = [(id, value) for value in data]
|
||||||
|
table.insert_many(*values, insert_keys=columns)
|
||||||
|
|
||||||
|
if cls._cache is not None:
|
||||||
|
cls._cache[id] = data
|
||||||
|
|
||||||
|
|
||||||
|
class UserInputError(SafeCancellation):
|
||||||
|
pass
|
||||||
170
bot/settings/guild_settings.py
Normal file
170
bot/settings/guild_settings.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import datetime
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
|
||||||
|
import settings
|
||||||
|
from utils.lib import DotDict
|
||||||
|
from utils import seekers # noqa
|
||||||
|
|
||||||
|
from wards import guild_admin
|
||||||
|
from data import tables as tb
|
||||||
|
|
||||||
|
|
||||||
|
class GuildSettings(settings.ObjectSettings):
|
||||||
|
settings = DotDict()
|
||||||
|
|
||||||
|
|
||||||
|
class GuildSetting(settings.ColumnData, settings.Setting):
|
||||||
|
_table_interface = tb.guild_config
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_create_row = True
|
||||||
|
|
||||||
|
category = None
|
||||||
|
|
||||||
|
write_ward = guild_admin
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class event_log(settings.Channel, GuildSetting):
|
||||||
|
category = "Meta"
|
||||||
|
|
||||||
|
attr_name = 'event_log'
|
||||||
|
_data_column = 'event_log_channel'
|
||||||
|
|
||||||
|
display_name = "event_log"
|
||||||
|
desc = "Bot event logging channel."
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Channel to post 'events', such as workouts completing or members renting a room."
|
||||||
|
)
|
||||||
|
|
||||||
|
_chan_type = discord.ChannelType.text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The event log is now {}.".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The event log has been unset."
|
||||||
|
|
||||||
|
def log(self, description="", colour=discord.Color.orange(), **kwargs):
|
||||||
|
channel = self.value
|
||||||
|
if channel:
|
||||||
|
embed = discord.Embed(
|
||||||
|
description=description,
|
||||||
|
colour=colour,
|
||||||
|
timestamp=datetime.datetime.utcnow(),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
asyncio.create_task(channel.send(embed=embed))
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class admin_role(settings.Role, GuildSetting):
|
||||||
|
category = "Guild Roles"
|
||||||
|
|
||||||
|
attr_name = 'admin_role'
|
||||||
|
_data_column = 'admin_role'
|
||||||
|
|
||||||
|
display_name = "admin_role"
|
||||||
|
desc = "Server administrator role."
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Server administrator role.\n"
|
||||||
|
"Allows usage of the administrative commands, such as `config`.\n"
|
||||||
|
"These commands may also be used by anyone with the discord adminitrator permission."
|
||||||
|
)
|
||||||
|
# TODO Expand on what these are.
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The administrator role is now {}.".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The administrator role has been unset."
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class mod_role(settings.Role, GuildSetting):
|
||||||
|
category = "Guild Roles"
|
||||||
|
|
||||||
|
attr_name = 'mod_role'
|
||||||
|
_data_column = 'mod_role'
|
||||||
|
|
||||||
|
display_name = "mod_role"
|
||||||
|
desc = "Server moderator role."
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Server moderator role.\n"
|
||||||
|
"Allows usage of the modistrative commands."
|
||||||
|
)
|
||||||
|
# TODO Expand on what these are.
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The moderator role is now {}.".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The moderator role has been unset."
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class unranked_roles(settings.RoleList, settings.ListData, settings.Setting):
|
||||||
|
category = "Guild Roles"
|
||||||
|
|
||||||
|
attr_name = 'unranked_roles'
|
||||||
|
|
||||||
|
_table_interface = tb.unranked_roles
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_data_column = 'roleid'
|
||||||
|
|
||||||
|
write_ward = guild_admin
|
||||||
|
display_name = "unranked_roles"
|
||||||
|
desc = "Roles to exclude from the leaderboards."
|
||||||
|
|
||||||
|
_force_unique = True
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Roles to be excluded from the `top` and `topcoins` leaderboards."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat cache, no need to expire objects
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The following roles will be excluded from the leaderboard:\n{}".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The excluded roles have been removed."
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class donator_roles(settings.RoleList, settings.ListData, settings.Setting):
|
||||||
|
category = "Guild Roles"
|
||||||
|
|
||||||
|
attr_name = 'donator_roles'
|
||||||
|
|
||||||
|
_table_interface = tb.donator_roles
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_data_column = 'roleid'
|
||||||
|
|
||||||
|
write_ward = guild_admin
|
||||||
|
display_name = "donator_roles"
|
||||||
|
desc = "Donator badge roles."
|
||||||
|
|
||||||
|
_force_unique = True
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Members with these roles will be considered donators and have access to premium features."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat cache, no need to expire objects
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The donator badges are now:\n{}".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The donator badges have been removed."
|
||||||
714
bot/settings/setting_types.py
Normal file
714
bot/settings/setting_types.py
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
import itertools
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import discord
|
||||||
|
from cmdClient.Context import Context
|
||||||
|
from cmdClient.lib import SafeCancellation
|
||||||
|
|
||||||
|
from meta import client
|
||||||
|
|
||||||
|
from .base import UserInputError
|
||||||
|
|
||||||
|
|
||||||
|
class SettingType:
|
||||||
|
"""
|
||||||
|
Abstract class representing a setting type.
|
||||||
|
Intended to be used as a mixin for a Setting,
|
||||||
|
with the provided methods implementing converter methods for the setting.
|
||||||
|
"""
|
||||||
|
accepts: str = None # User readable description of the acceptable values
|
||||||
|
|
||||||
|
# Raw converters
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value, **kwargs):
|
||||||
|
"""
|
||||||
|
Convert a high-level setting value to internal data.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Any, **kwargs):
|
||||||
|
"""
|
||||||
|
Convert internal data to high-level setting value.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Parse user provided input into internal data.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: Any, **kwargs):
|
||||||
|
"""
|
||||||
|
Convert internal data into a formatted user-readable string.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Boolean(SettingType):
|
||||||
|
"""
|
||||||
|
Boolean type, supporting truthy and falsey user input.
|
||||||
|
Configurable to change truthy and falsey values, and the output map.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[bool]
|
||||||
|
The stored boolean value.
|
||||||
|
value: Optional[bool]
|
||||||
|
The stored boolean value.
|
||||||
|
"""
|
||||||
|
accepts = "Yes/No, On/Off, True/False, Enabled/Disabled"
|
||||||
|
|
||||||
|
# Values that are accepted as truthy and falsey by the parser
|
||||||
|
_truthy = {"yes", "true", "on", "enable", "enabled"}
|
||||||
|
_falsey = {"no", "false", "off", "disable", "disabled"}
|
||||||
|
|
||||||
|
# The user-friendly output strings to use for each value
|
||||||
|
_outputs = {True: "On", False: "Off", None: "Not Set"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[bool], **kwargs):
|
||||||
|
"""
|
||||||
|
Both data and value are of type Optional[bool].
|
||||||
|
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[bool].
|
||||||
|
Directly return the internal data as the value.
|
||||||
|
"""
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Looks up the provided string in the truthy and falsey tables.
|
||||||
|
"""
|
||||||
|
_userstr = userstr.lower()
|
||||||
|
if _userstr == "none":
|
||||||
|
return None
|
||||||
|
if _userstr in cls._truthy:
|
||||||
|
return True
|
||||||
|
elif _userstr in cls._falsey:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise UserInputError("Unknown boolean type `{}`".format(userstr))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: bool, **kwargs):
|
||||||
|
"""
|
||||||
|
Pass the provided value through the outputs map.
|
||||||
|
"""
|
||||||
|
return cls._outputs[data]
|
||||||
|
|
||||||
|
|
||||||
|
class Integer(SettingType):
|
||||||
|
"""
|
||||||
|
Integer type. Storing any integer.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[int]
|
||||||
|
The stored integer value.
|
||||||
|
value: Optional[int]
|
||||||
|
The stored integer value.
|
||||||
|
"""
|
||||||
|
accepts = "An integer."
|
||||||
|
|
||||||
|
# Set limits on the possible integers
|
||||||
|
_min = -4096
|
||||||
|
_max = 4096
|
||||||
|
|
||||||
|
@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 userstr.lower() == "none":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
num = int(userstr)
|
||||||
|
except Exception:
|
||||||
|
raise UserInputError("Couldn't parse provided integer.") from None
|
||||||
|
|
||||||
|
if num > cls._max:
|
||||||
|
raise UserInputError("Provided integer was too large!")
|
||||||
|
elif num < cls._min:
|
||||||
|
raise UserInputError("Provided integer was too small!")
|
||||||
|
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
|
class String(SettingType):
|
||||||
|
"""
|
||||||
|
String type, storing arbitrary text.
|
||||||
|
Configurable to limit text length and restrict input options.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[str]
|
||||||
|
The stored string.
|
||||||
|
value: Optional[str]
|
||||||
|
The stored string.
|
||||||
|
"""
|
||||||
|
accepts = "Any text"
|
||||||
|
|
||||||
|
# Maximum length of string to accept
|
||||||
|
_maxlen: int = None
|
||||||
|
|
||||||
|
# Set of input options to accept
|
||||||
|
_options: set = None
|
||||||
|
|
||||||
|
# Whether to quote the string as code
|
||||||
|
_quote: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[str], **kwargs):
|
||||||
|
"""
|
||||||
|
Return the provided value string as the data string.
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Optional[str], **kwargs):
|
||||||
|
"""
|
||||||
|
Return the provided data string as the value string.
|
||||||
|
"""
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Check that the user-entered string is of the correct length.
|
||||||
|
Accept "None" to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() == "none":
|
||||||
|
# Unsetting case
|
||||||
|
return None
|
||||||
|
elif cls._maxlen is not None and len(userstr) > cls._maxlen:
|
||||||
|
raise UserInputError("Provided string was too long! Maximum length is `{}`".format(cls._maxlen))
|
||||||
|
elif cls._options is not None and not userstr.lower() in cls._options:
|
||||||
|
raise UserInputError("Invalid option! Valid options are `{}`".format("`, `".join(cls._options)))
|
||||||
|
else:
|
||||||
|
return userstr
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrap the string in backtics for formatting.
|
||||||
|
Handle the special case where the string is empty.
|
||||||
|
"""
|
||||||
|
if data:
|
||||||
|
return "`{}`".format(data) if cls._quote else str(data)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(SettingType):
|
||||||
|
"""
|
||||||
|
Channel type, storing a single `discord.Channel`.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[int]
|
||||||
|
The id of the stored Channel.
|
||||||
|
value: Optional[discord.abc.GuildChannel]
|
||||||
|
The stored Channel.
|
||||||
|
"""
|
||||||
|
accepts = "Channel mention/id/name, or 'None' to unset"
|
||||||
|
|
||||||
|
# Type of channel, if any
|
||||||
|
_chan_type: discord.ChannelType = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[discord.abc.GuildChannel], **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the channel id.
|
||||||
|
"""
|
||||||
|
return value.id if value is not None else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Optional[int], **kwargs):
|
||||||
|
"""
|
||||||
|
Uses the client to look up the channel id.
|
||||||
|
Returns the Channel if found, otherwise None.
|
||||||
|
"""
|
||||||
|
# Always passthrough None
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return client.get_channel(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Pass to the channel seeker utility to find the requested channel.
|
||||||
|
Handle `0` and variants of `None` to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() in ('0', 'none'):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
channel = await ctx.find_channel(userstr, interactive=True, chan_type=cls._chan_type)
|
||||||
|
if channel is None:
|
||||||
|
raise SafeCancellation
|
||||||
|
else:
|
||||||
|
return channel.id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: Optional[int], **kwargs):
|
||||||
|
"""
|
||||||
|
Retrieve an artificially created channel mention.
|
||||||
|
If the channel does not exist, this will show up as invalid-channel.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return "<#{}>".format(data)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceChannel(Channel):
|
||||||
|
_chan_type = discord.ChannelType.voice
|
||||||
|
|
||||||
|
|
||||||
|
class TextChannel(Channel):
|
||||||
|
_chan_type = discord.ChannelType.text
|
||||||
|
|
||||||
|
|
||||||
|
class Role(SettingType):
|
||||||
|
"""
|
||||||
|
Role type, storing a single `discord.Role`.
|
||||||
|
Configurably allows returning roles which don't exist or are not seen by the client
|
||||||
|
as `discord.Object`.
|
||||||
|
|
||||||
|
Settings may override `get_guildid` if the setting object `id` is not the guildid.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[int]
|
||||||
|
The id of the stored Role.
|
||||||
|
value: Optional[Union[discord.Role, discord.Object]]
|
||||||
|
The stored Role, or, if the role wasn't found and `_strict` is not set,
|
||||||
|
a discord Object with the role id set.
|
||||||
|
"""
|
||||||
|
accepts = "Role mention/id/name, or 'None' to unset"
|
||||||
|
|
||||||
|
# Whether to disallow returning roles which don't exist as `discord.Object`s
|
||||||
|
_strict = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[discord.Role], **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the role id.
|
||||||
|
"""
|
||||||
|
return value.id if value is not None else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Optional[int], **kwargs):
|
||||||
|
"""
|
||||||
|
Uses the client to look up the guild and role id.
|
||||||
|
Returns the role if found, otherwise returns a `discord.Object` with the id set,
|
||||||
|
depending on the `_strict` setting.
|
||||||
|
"""
|
||||||
|
# Always passthrough None
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch guildid
|
||||||
|
guildid = cls._get_guildid(id, **kwargs)
|
||||||
|
|
||||||
|
# Search for the role
|
||||||
|
role = None
|
||||||
|
guild = client.get_guild(guildid)
|
||||||
|
if guild is not None:
|
||||||
|
role = guild.get_role(data)
|
||||||
|
|
||||||
|
if role is not None:
|
||||||
|
return role
|
||||||
|
elif not cls._strict:
|
||||||
|
return discord.Object(id=data)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Pass to the role seeker utility to find the requested role.
|
||||||
|
Handle `0` and variants of `None` to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() in ('0', 'none'):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
role = await ctx.find_role(userstr, create=False, interactive=True)
|
||||||
|
if role is None:
|
||||||
|
raise SafeCancellation
|
||||||
|
else:
|
||||||
|
return role.id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: Optional[int], **kwargs):
|
||||||
|
"""
|
||||||
|
Retrieve the role mention if found, otherwise the role id or None depending on `_strict`.
|
||||||
|
"""
|
||||||
|
role = cls._data_to_value(id, data, **kwargs)
|
||||||
|
if role is None:
|
||||||
|
return "Not Set"
|
||||||
|
elif isinstance(role, discord.Role):
|
||||||
|
return role.mention
|
||||||
|
else:
|
||||||
|
return "`{}`".format(role.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_guildid(cls, id: int, **kwargs):
|
||||||
|
"""
|
||||||
|
Fetch the current guildid.
|
||||||
|
Assumes that the guilid is either passed as a kwarg or is the object id.
|
||||||
|
Should be overriden in other cases.
|
||||||
|
"""
|
||||||
|
return kwargs.get('guildid', id)
|
||||||
|
|
||||||
|
|
||||||
|
class Emoji(SettingType):
|
||||||
|
"""
|
||||||
|
Emoji type. Stores both custom and unicode emojis.
|
||||||
|
"""
|
||||||
|
accepts = "Emoji, either built in or custom. Use 'None' to unset."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_emoji(emojistr):
|
||||||
|
"""
|
||||||
|
Converts a provided string into a PartialEmoji.
|
||||||
|
If the string is badly formatted, returns None.
|
||||||
|
"""
|
||||||
|
if ":" in emojistr:
|
||||||
|
emojistr = emojistr.strip('<>')
|
||||||
|
splits = emojistr.split(":")
|
||||||
|
if len(splits) == 3:
|
||||||
|
animated, name, id = splits
|
||||||
|
animated = bool(animated)
|
||||||
|
return discord.PartialEmoji(name, animated=animated, id=int(id))
|
||||||
|
else:
|
||||||
|
# TODO: Check whether this is a valid emoji
|
||||||
|
return discord.PartialEmoji(emojistr)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[discord.PartialEmoji], **kwargs):
|
||||||
|
"""
|
||||||
|
Both data and value are of type Optional[discord.PartialEmoji].
|
||||||
|
Directly return the provided value as data.
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Optional[discord.PartialEmoji], **kwargs):
|
||||||
|
"""
|
||||||
|
Both data and value are of type Optional[discord.PartialEmoji].
|
||||||
|
Directly return the internal data as the value.
|
||||||
|
"""
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Pass to the emoji string parser to get the emoji.
|
||||||
|
Handle `0` and variants of `None` to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() in ('0', 'none'):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return cls._parse_emoji(userstr)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: Optional[discord.PartialEmoji], **kwargs):
|
||||||
|
"""
|
||||||
|
Return a string form of the partial emoji, which generally displays the emoji.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
|
class Timezone(SettingType):
|
||||||
|
"""
|
||||||
|
Timezone type, storing a valid timezone string.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[str]
|
||||||
|
The string representing the timezone in POSIX format.
|
||||||
|
value: Optional[timezone]
|
||||||
|
The pytz timezone.
|
||||||
|
"""
|
||||||
|
accepts = (
|
||||||
|
"A timezone name from [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) "
|
||||||
|
"(e.g. `Europe/London`)."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: Optional[str], **kwargs):
|
||||||
|
"""
|
||||||
|
Return the provided value string as the data string.
|
||||||
|
"""
|
||||||
|
if value is not None:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: Optional[str], **kwargs):
|
||||||
|
"""
|
||||||
|
Return the provided data string as the value string.
|
||||||
|
"""
|
||||||
|
if data is not None:
|
||||||
|
return pytz.timezone(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Check that the user-entered string is of the correct length.
|
||||||
|
Accept "None" to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() == "none":
|
||||||
|
# Unsetting case
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timezone = pytz.timezone(userstr)
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
timezones = [tz for tz in pytz.all_timezones if userstr.lower() in tz.lower()]
|
||||||
|
if len(timezones) == 1:
|
||||||
|
timezone = timezones[0]
|
||||||
|
elif timezones:
|
||||||
|
result = await ctx.selector(
|
||||||
|
"Multiple matching timezones found, please select one.",
|
||||||
|
timezones
|
||||||
|
)
|
||||||
|
timezone = timezones[result]
|
||||||
|
else:
|
||||||
|
raise UserInputError(
|
||||||
|
"Unknown timezone `{}`. "
|
||||||
|
"Please provide a TZ name from "
|
||||||
|
"[this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)".format(userstr)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
return str(timezone)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrap the string in backtics for formatting.
|
||||||
|
Handle the special case where the string is empty.
|
||||||
|
"""
|
||||||
|
if data:
|
||||||
|
return "`{}`".format(data)
|
||||||
|
else:
|
||||||
|
return 'Not Set'
|
||||||
|
|
||||||
|
|
||||||
|
class IntegerEnum(SettingType):
|
||||||
|
"""
|
||||||
|
Integer Enum type, accepting limited strings, storing an integer, and returning an IntEnum value
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: Optional[int]
|
||||||
|
The stored integer.
|
||||||
|
value: Optional[Any]
|
||||||
|
The corresponding Enum member
|
||||||
|
"""
|
||||||
|
accepts = "A valid option."
|
||||||
|
|
||||||
|
# Enum to use for mapping values
|
||||||
|
_enum: IntEnum = None
|
||||||
|
|
||||||
|
# Custom map to format the value. If None, uses the enum names.
|
||||||
|
_output_map = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, value: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Return the value corresponding to the enum member
|
||||||
|
"""
|
||||||
|
if value is not None:
|
||||||
|
return value.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Return the enum member corresponding to the provided integer
|
||||||
|
"""
|
||||||
|
if data is not None:
|
||||||
|
return cls._enum(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Find the corresponding enum member's value to the provided user input.
|
||||||
|
Accept "None" to unset.
|
||||||
|
"""
|
||||||
|
userstr = userstr.lower()
|
||||||
|
|
||||||
|
options = {name.lower(): mem.value for name, mem in cls._enum.__members__.items()}
|
||||||
|
|
||||||
|
if userstr == "none":
|
||||||
|
# Unsetting case
|
||||||
|
return None
|
||||||
|
elif userstr not in options:
|
||||||
|
raise UserInputError("Invalid option!")
|
||||||
|
else:
|
||||||
|
return options[userstr]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: int, **kwargs):
|
||||||
|
"""
|
||||||
|
Format the data using either the `_enum` or the provided output map.
|
||||||
|
"""
|
||||||
|
if data is not None:
|
||||||
|
value = cls._enum(data)
|
||||||
|
if cls._output_map:
|
||||||
|
return cls._output_map[value]
|
||||||
|
else:
|
||||||
|
return "`{}`".format(value.name)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingList(SettingType):
|
||||||
|
"""
|
||||||
|
List of a particular type of setting.
|
||||||
|
|
||||||
|
Types:
|
||||||
|
data: List[SettingType.data]
|
||||||
|
List of data types of the specified SettingType.
|
||||||
|
Some of the data may be None.
|
||||||
|
value: List[SettingType.value]
|
||||||
|
List of the value types of the specified SettingType.
|
||||||
|
Some of the values may be None.
|
||||||
|
"""
|
||||||
|
# Base setting type to make the list from
|
||||||
|
_setting = None # type: SettingType
|
||||||
|
|
||||||
|
# Whether 'None' values are filtered out of the data when creating values
|
||||||
|
_allow_null_values = False # type: bool
|
||||||
|
|
||||||
|
# Whether duplicate data values should be filtered out
|
||||||
|
_force_unique = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_from_value(cls, id: int, values: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the setting type data for each value in the value list
|
||||||
|
"""
|
||||||
|
if values is None:
|
||||||
|
# Special behaviour here, store an empty list instead of None
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return [cls._setting._data_from_value(id, value) for value in values]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _data_to_value(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the setting type value for each entry in the data list
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
values = [cls._setting._data_to_value(id, entry) for entry in data]
|
||||||
|
|
||||||
|
# Filter out null values if required
|
||||||
|
if not cls._allow_null_values:
|
||||||
|
values = [value for value in values if value is not None]
|
||||||
|
return values
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Splits the user string across `,` to break up the list.
|
||||||
|
Handle `0` and variants of `None` to unset.
|
||||||
|
"""
|
||||||
|
if userstr.lower() in ('0', 'none'):
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
data = []
|
||||||
|
for item in userstr.split(','):
|
||||||
|
data.append(await cls._setting._parse_userstr(ctx, id, item.strip()))
|
||||||
|
|
||||||
|
if cls._force_unique:
|
||||||
|
data = list(set(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id: int, data: ..., **kwargs):
|
||||||
|
"""
|
||||||
|
Format the list by adding `,` between each formatted item
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
formatted_items = []
|
||||||
|
for item in data:
|
||||||
|
formatted_item = cls._setting._format_data(id, item)
|
||||||
|
if formatted_item is not None:
|
||||||
|
formatted_items.append(formatted_item)
|
||||||
|
return ", ".join(formatted_items)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelList(SettingList):
|
||||||
|
"""
|
||||||
|
List of channels
|
||||||
|
"""
|
||||||
|
accepts = (
|
||||||
|
"Comma separated list of channel mentions/ids/names. Use `None` to unset. "
|
||||||
|
"Write `--add` or `--remove` to add or remove channels."
|
||||||
|
)
|
||||||
|
_setting = Channel
|
||||||
|
|
||||||
|
|
||||||
|
class RoleList(SettingList):
|
||||||
|
"""
|
||||||
|
List of roles
|
||||||
|
"""
|
||||||
|
accepts = (
|
||||||
|
"Comma separated list of role mentions/ids/names. Use `None` to unset. "
|
||||||
|
"Write `--add` or `--remove` to add or remove roles."
|
||||||
|
)
|
||||||
|
_setting = Role
|
||||||
|
|
||||||
|
@property
|
||||||
|
def members(self):
|
||||||
|
roles = self.value
|
||||||
|
return list(set(itertools.chain(*(role.members for role in roles))))
|
||||||
|
|
||||||
|
|
||||||
|
class StringList(SettingList):
|
||||||
|
"""
|
||||||
|
List of strings
|
||||||
|
"""
|
||||||
|
accepts = (
|
||||||
|
"Comma separated list of strings. Use `None` to unset. "
|
||||||
|
"Write `--add` or `--remove` to add or remove strings."
|
||||||
|
)
|
||||||
|
_setting = String
|
||||||
42
bot/settings/user_settings.py
Normal file
42
bot/settings/user_settings.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import settings
|
||||||
|
from utils.lib import DotDict
|
||||||
|
|
||||||
|
from data import tables as tb
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettings(settings.ObjectSettings):
|
||||||
|
settings = DotDict()
|
||||||
|
|
||||||
|
|
||||||
|
class UserSetting(settings.ColumnData, settings.Setting):
|
||||||
|
_table_interface = tb.user_config
|
||||||
|
_id_column = 'userid'
|
||||||
|
_create_row = True
|
||||||
|
|
||||||
|
write_ward = None
|
||||||
|
|
||||||
|
|
||||||
|
@UserSettings.attach_setting
|
||||||
|
class timezone(settings.Timezone, UserSetting):
|
||||||
|
attr_name = 'timezone'
|
||||||
|
_data_column = 'timezone'
|
||||||
|
|
||||||
|
_default = 'UTC'
|
||||||
|
|
||||||
|
display_name = 'timezone'
|
||||||
|
desc = "Timezone to display prompts in."
|
||||||
|
long_desc = (
|
||||||
|
"Timezone used for displaying certain prompts (e.g. selecting an accountability room)."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return (
|
||||||
|
"Your personal timezone is now {}.\n"
|
||||||
|
"Your current time is **{}**."
|
||||||
|
).format(self.formatted, datetime.datetime.now(tz=self.value).strftime("%H:%M"))
|
||||||
|
else:
|
||||||
|
return "Your personal timezone has been unset."
|
||||||
64
bot/utils/ctx_addons.py
Normal file
64
bot/utils/ctx_addons.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import discord
|
||||||
|
from cmdClient import Context
|
||||||
|
|
||||||
|
from data import tables
|
||||||
|
from core import Lion
|
||||||
|
from settings import GuildSettings, UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def embed_reply(ctx, desc, colour=discord.Colour(0x9b59b6), **kwargs):
|
||||||
|
"""
|
||||||
|
Simple helper to embed replies.
|
||||||
|
All arguments are passed to the embed constructor.
|
||||||
|
`desc` is passed as the `description` kwarg.
|
||||||
|
"""
|
||||||
|
embed = discord.Embed(description=desc, colour=colour, **kwargs)
|
||||||
|
return await ctx.reply(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def error_reply(ctx, error_str, **kwargs):
|
||||||
|
"""
|
||||||
|
Notify the user of a user level error.
|
||||||
|
Typically, this will occur in a red embed, posted in the command channel.
|
||||||
|
"""
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.red(),
|
||||||
|
description=error_str
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
message = await ctx.ch.send(embed=embed, reference=ctx.msg, **kwargs)
|
||||||
|
ctx.sent_messages.append(message)
|
||||||
|
return message
|
||||||
|
except discord.Forbidden:
|
||||||
|
message = await ctx.reply(error_str)
|
||||||
|
ctx.sent_messages.append(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def context_property(func):
|
||||||
|
setattr(Context, func.__name__, property(func))
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
@context_property
|
||||||
|
def best_prefix(ctx):
|
||||||
|
return ctx.client.prefix
|
||||||
|
|
||||||
|
|
||||||
|
@context_property
|
||||||
|
def guild_settings(ctx):
|
||||||
|
if ctx.guild:
|
||||||
|
tables.guild_config.fetch_or_create(ctx.guild.id)
|
||||||
|
return GuildSettings(ctx.guild.id if ctx.guild else 0)
|
||||||
|
|
||||||
|
|
||||||
|
@context_property
|
||||||
|
def author_settings(ctx):
|
||||||
|
return UserSettings(ctx.author.id)
|
||||||
|
|
||||||
|
|
||||||
|
@context_property
|
||||||
|
def alion(ctx):
|
||||||
|
return Lion.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id)
|
||||||
331
bot/utils/seekers.py
Normal file
331
bot/utils/seekers.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import discord
|
||||||
|
|
||||||
|
from cmdClient import Context
|
||||||
|
from cmdClient.lib import InvalidContext, UserCancelled, ResponseTimedOut, SafeCancellation
|
||||||
|
from . import interactive
|
||||||
|
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def find_role(ctx, userstr, create=False, interactive=False, collection=None, allow_notfound=True):
|
||||||
|
"""
|
||||||
|
Find a guild role given a partial matching string,
|
||||||
|
allowing custom role collections and several behavioural switches.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
userstr: str
|
||||||
|
String obtained from a user, expected to partially match a role in the collection.
|
||||||
|
The string will be tested against both the id and the name of the role.
|
||||||
|
create: bool
|
||||||
|
Whether to offer to create the role if it does not exist.
|
||||||
|
The bot will only offer to create the role if it has the `manage_channels` permission.
|
||||||
|
interactive: bool
|
||||||
|
Whether to offer the user a list of roles to choose from,
|
||||||
|
or pick the first matching role.
|
||||||
|
collection: List[Union[discord.Role, discord.Object]]
|
||||||
|
Collection of roles to search amongst.
|
||||||
|
If none, uses the guild role list.
|
||||||
|
allow_notfound: bool
|
||||||
|
Whether to return `None` when there are no matches, instead of raising `SafeCancellation`.
|
||||||
|
Overriden by `create`, if it is set.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
discord.Role:
|
||||||
|
If a valid role is found.
|
||||||
|
None:
|
||||||
|
If no valid role has been found.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
cmdClient.lib.UserCancelled:
|
||||||
|
If the user cancels interactive role selection.
|
||||||
|
cmdClient.lib.ResponseTimedOut:
|
||||||
|
If the user fails to respond to interactive role selection within `60` seconds`
|
||||||
|
cmdClient.lib.SafeCancellation:
|
||||||
|
If `allow_notfound` is `False`, and the search returned no matches.
|
||||||
|
"""
|
||||||
|
# Handle invalid situations and input
|
||||||
|
if not ctx.guild:
|
||||||
|
raise InvalidContext("Attempt to use find_role outside of a guild.")
|
||||||
|
|
||||||
|
if userstr == "":
|
||||||
|
raise ValueError("User string passed to find_role was empty.")
|
||||||
|
|
||||||
|
# Create the collection to search from args or guild roles
|
||||||
|
collection = collection if collection is not None else ctx.guild.roles
|
||||||
|
|
||||||
|
# If the unser input was a number or possible role mention, get it out
|
||||||
|
userstr = userstr.strip()
|
||||||
|
roleid = userstr.strip('<#@&!> ')
|
||||||
|
roleid = int(roleid) if roleid.isdigit() else None
|
||||||
|
searchstr = userstr.lower()
|
||||||
|
|
||||||
|
# Find the role
|
||||||
|
role = None
|
||||||
|
|
||||||
|
# Check method to determine whether a role matches
|
||||||
|
def check(role):
|
||||||
|
return (role.id == roleid) or (searchstr in role.name.lower())
|
||||||
|
|
||||||
|
# Get list of matching roles
|
||||||
|
roles = list(filter(check, collection))
|
||||||
|
|
||||||
|
if len(roles) == 0:
|
||||||
|
# Nope
|
||||||
|
role = None
|
||||||
|
elif len(roles) == 1:
|
||||||
|
# Select our lucky winner
|
||||||
|
role = roles[0]
|
||||||
|
else:
|
||||||
|
# We have multiple matching roles!
|
||||||
|
if interactive:
|
||||||
|
# Interactive prompt with the list of roles, handle `Object`s
|
||||||
|
role_names = [
|
||||||
|
role.name if isinstance(role, discord.Role) else str(role.id) for role in roles
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = await ctx.selector(
|
||||||
|
"`{}` roles found matching `{}`!".format(len(roles), userstr),
|
||||||
|
role_names,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
except UserCancelled:
|
||||||
|
raise UserCancelled("User cancelled role selection.") from None
|
||||||
|
except ResponseTimedOut:
|
||||||
|
raise ResponseTimedOut("Role selection timed out.") from None
|
||||||
|
|
||||||
|
role = roles[selected]
|
||||||
|
else:
|
||||||
|
# Just select the first one
|
||||||
|
role = roles[0]
|
||||||
|
|
||||||
|
# Handle non-existence of the role
|
||||||
|
if role is None:
|
||||||
|
msgstr = "Couldn't find a role matching `{}`!".format(userstr)
|
||||||
|
if create:
|
||||||
|
# Inform the user
|
||||||
|
msg = await ctx.error_reply(msgstr)
|
||||||
|
if ctx.guild.me.guild_permissions.manage_roles:
|
||||||
|
# Offer to create it
|
||||||
|
resp = await ctx.ask("Would you like to create this role?", timeout=30)
|
||||||
|
if resp:
|
||||||
|
# They accepted, create the role
|
||||||
|
# Before creation, check if the role name is too long
|
||||||
|
if len(userstr) > 100:
|
||||||
|
await ctx.error_reply("Could not create a role with a name over 100 characters long!")
|
||||||
|
else:
|
||||||
|
role = await ctx.guild.create_role(
|
||||||
|
name=userstr,
|
||||||
|
reason="Interactive role creation for {} (uid:{})".format(ctx.author, ctx.author.id)
|
||||||
|
)
|
||||||
|
await msg.delete()
|
||||||
|
await ctx.reply("You have created the role `{}`!".format(userstr))
|
||||||
|
|
||||||
|
# If we still don't have a role, cancel unless allow_notfound is set
|
||||||
|
if role is None and not allow_notfound:
|
||||||
|
raise SafeCancellation
|
||||||
|
elif not allow_notfound:
|
||||||
|
raise SafeCancellation(msgstr)
|
||||||
|
else:
|
||||||
|
await ctx.error_reply(msgstr)
|
||||||
|
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def find_channel(ctx, userstr, interactive=False, collection=None, chan_type=None):
|
||||||
|
"""
|
||||||
|
Find a guild channel given a partial matching string,
|
||||||
|
allowing custom channel collections and several behavioural switches.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
userstr: str
|
||||||
|
String obtained from a user, expected to partially match a channel in the collection.
|
||||||
|
The string will be tested against both the id and the name of the channel.
|
||||||
|
interactive: bool
|
||||||
|
Whether to offer the user a list of channels to choose from,
|
||||||
|
or pick the first matching channel.
|
||||||
|
collection: List(discord.Channel)
|
||||||
|
Collection of channels to search amongst.
|
||||||
|
If none, uses the full guild channel list.
|
||||||
|
chan_type: discord.ChannelType
|
||||||
|
Type of channel to restrict the collection to.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
discord.Channel:
|
||||||
|
If a valid channel is found.
|
||||||
|
None:
|
||||||
|
If no valid channel has been found.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
cmdClient.lib.UserCancelled:
|
||||||
|
If the user cancels interactive channel selection.
|
||||||
|
cmdClient.lib.ResponseTimedOut:
|
||||||
|
If the user fails to respond to interactive channel selection within `60` seconds`
|
||||||
|
"""
|
||||||
|
# Handle invalid situations and input
|
||||||
|
if not ctx.guild:
|
||||||
|
raise InvalidContext("Attempt to use find_channel outside of a guild.")
|
||||||
|
|
||||||
|
if userstr == "":
|
||||||
|
raise ValueError("User string passed to find_channel was empty.")
|
||||||
|
|
||||||
|
# Create the collection to search from args or guild channels
|
||||||
|
collection = collection if collection else ctx.guild.channels
|
||||||
|
if chan_type is not None:
|
||||||
|
collection = [chan for chan in collection if chan.type == chan_type]
|
||||||
|
|
||||||
|
# If the user input was a number or possible channel mention, extract it
|
||||||
|
chanid = userstr.strip('<#@&!>')
|
||||||
|
chanid = int(chanid) if chanid.isdigit() else None
|
||||||
|
searchstr = userstr.lower()
|
||||||
|
|
||||||
|
# Find the channel
|
||||||
|
chan = None
|
||||||
|
|
||||||
|
# Check method to determine whether a channel matches
|
||||||
|
def check(chan):
|
||||||
|
return (chan.id == chanid) or (searchstr in chan.name.lower())
|
||||||
|
|
||||||
|
# Get list of matching roles
|
||||||
|
channels = list(filter(check, collection))
|
||||||
|
|
||||||
|
if len(channels) == 0:
|
||||||
|
# Nope
|
||||||
|
chan = None
|
||||||
|
elif len(channels) == 1:
|
||||||
|
# Select our lucky winner
|
||||||
|
chan = channels[0]
|
||||||
|
else:
|
||||||
|
# We have multiple matching channels!
|
||||||
|
if interactive:
|
||||||
|
# Interactive prompt with the list of channels
|
||||||
|
chan_names = [chan.name for chan in channels]
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = await ctx.selector(
|
||||||
|
"`{}` channels found matching `{}`!".format(len(channels), userstr),
|
||||||
|
chan_names,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
except UserCancelled:
|
||||||
|
raise UserCancelled("User cancelled channel selection.") from None
|
||||||
|
except ResponseTimedOut:
|
||||||
|
raise ResponseTimedOut("Channel selection timed out.") from None
|
||||||
|
|
||||||
|
chan = channels[selected]
|
||||||
|
else:
|
||||||
|
# Just select the first one
|
||||||
|
chan = channels[0]
|
||||||
|
|
||||||
|
if chan is None:
|
||||||
|
await ctx.error_reply("Couldn't find a channel matching `{}`!".format(userstr))
|
||||||
|
|
||||||
|
return chan
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def find_member(ctx, userstr, interactive=False, collection=None, silent=False):
|
||||||
|
"""
|
||||||
|
Find a guild member given a partial matching string,
|
||||||
|
allowing custom member collections.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
userstr: str
|
||||||
|
String obtained from a user, expected to partially match a member in the collection.
|
||||||
|
The string will be tested against both the userid, full user name and user nickname.
|
||||||
|
interactive: bool
|
||||||
|
Whether to offer the user a list of members to choose from,
|
||||||
|
or pick the first matching channel.
|
||||||
|
collection: List(discord.Member)
|
||||||
|
Collection of members to search amongst.
|
||||||
|
If none, uses the full guild member list.
|
||||||
|
silent: bool
|
||||||
|
Whether to reply with an error when there are no matches.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
discord.Member:
|
||||||
|
If a valid member is found.
|
||||||
|
None:
|
||||||
|
If no valid member has been found.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
cmdClient.lib.UserCancelled:
|
||||||
|
If the user cancels interactive member selection.
|
||||||
|
cmdClient.lib.ResponseTimedOut:
|
||||||
|
If the user fails to respond to interactive member selection within `60` seconds`
|
||||||
|
"""
|
||||||
|
# Handle invalid situations and input
|
||||||
|
if not ctx.guild:
|
||||||
|
raise InvalidContext("Attempt to use find_member outside of a guild.")
|
||||||
|
|
||||||
|
if userstr == "":
|
||||||
|
raise ValueError("User string passed to find_member was empty.")
|
||||||
|
|
||||||
|
# Create the collection to search from args or guild members
|
||||||
|
collection = collection if collection else ctx.guild.members
|
||||||
|
|
||||||
|
# If the user input was a number or possible member mention, extract it
|
||||||
|
userid = userstr.strip('<#@&!>')
|
||||||
|
userid = int(userid) if userid.isdigit() else None
|
||||||
|
searchstr = userstr.lower()
|
||||||
|
|
||||||
|
# Find the member
|
||||||
|
member = None
|
||||||
|
|
||||||
|
# Check method to determine whether a member matches
|
||||||
|
def check(member):
|
||||||
|
return (
|
||||||
|
member.id == userid
|
||||||
|
or searchstr in member.display_name.lower()
|
||||||
|
or searchstr in str(member).lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get list of matching roles
|
||||||
|
members = list(filter(check, collection))
|
||||||
|
|
||||||
|
if len(members) == 0:
|
||||||
|
# Nope
|
||||||
|
member = None
|
||||||
|
elif len(members) == 1:
|
||||||
|
# Select our lucky winner
|
||||||
|
member = members[0]
|
||||||
|
else:
|
||||||
|
# We have multiple matching members!
|
||||||
|
if interactive:
|
||||||
|
# Interactive prompt with the list of members
|
||||||
|
member_names = [
|
||||||
|
"{} {}".format(
|
||||||
|
member.nick if member.nick else (member if members.count(member) > 1
|
||||||
|
else member.name),
|
||||||
|
("<{}>".format(member)) if member.nick else ""
|
||||||
|
) for member in members
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = await ctx.selector(
|
||||||
|
"`{}` members found matching `{}`!".format(len(members), userstr),
|
||||||
|
member_names,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
except UserCancelled:
|
||||||
|
raise UserCancelled("User cancelled member selection.") from None
|
||||||
|
except ResponseTimedOut:
|
||||||
|
raise ResponseTimedOut("Member selection timed out.") from None
|
||||||
|
|
||||||
|
member = members[selected]
|
||||||
|
else:
|
||||||
|
# Just select the first one
|
||||||
|
member = members[0]
|
||||||
|
|
||||||
|
if member is None and not silent:
|
||||||
|
await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr))
|
||||||
|
|
||||||
|
return member
|
||||||
Reference in New Issue
Block a user