rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -0,0 +1,7 @@
from .module import module
from . import guild_config
from . import statreset
from . import new_members
from . import reaction_roles
from . import economy

View File

@@ -0,0 +1,3 @@
from ..module import module
from . import set_coins

View File

@@ -0,0 +1,104 @@
import discord
import datetime
from wards import guild_admin
from settings import GuildSettings
from core import Lion
from ..module import module
POSTGRES_INT_MAX = 2147483647
@module.cmd(
"set_coins",
group="Guild Admin",
desc="Set coins on a member."
)
@guild_admin()
async def cmd_set(ctx):
"""
Usage``:
{prefix}set_coins <user mention> <amount>
Description:
Sets the given number of coins on the mentioned user.
If a number greater than 0 is mentioned, will add coins.
If a number less than 0 is mentioned, will remove coins.
Note: LionCoins on a member cannot be negative.
Example:
{prefix}set_coins {ctx.author.mention} 100
{prefix}set_coins {ctx.author.mention} -100
"""
# Extract target and amount
# Handle a slightly more flexible input than stated
splits = ctx.args.split()
digits = [isNumber(split) for split in splits[:2]]
mentions = ctx.msg.mentions
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
return await _send_usage(ctx)
if all(digits):
# Both are digits, hopefully one is a member id, and one is an amount.
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
if not target:
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
if not target:
return await _send_usage(ctx)
elif digits[0]:
amount, target = int(splits[0]), mentions[0]
elif digits[1]:
target, amount = mentions[0], int(splits[1])
# Fetch the associated lion
target_lion = Lion.fetch(ctx.guild.id, target.id)
# Check sanity conditions
if target == ctx.client.user:
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
if target.bot:
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
# Finally, send the amount and the ack message
# Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range
target_coins_to_set = target_lion.coins + amount
if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX:
target_lion.addCoins(amount)
elif target_coins_to_set < 0:
target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0
target_lion.addCoins(target_coins_to_set)
target_coins_to_set = 0
else:
return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX))
embed = discord.Embed(
title="Funds Set",
description="You have set LionCoins on {} to **{}**!".format(target.mention,target_coins_to_set),
colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow()
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
await ctx.reply(embed=embed, reference=ctx.msg)
GuildSettings(ctx.guild.id).event_log.log(
"{} set {}'s LionCoins to`{}`.".format(
ctx.author.mention,
target.mention,
target_coins_to_set
),
title="Funds Set"
)
def isNumber(var):
try:
return isinstance(int(var), int)
except:
return False
async def _send_usage(ctx):
return await ctx.error_reply(
"**Usage:** `{prefix}set_coins <mention> <amount>`\n"
"**Example:**\n"
" {prefix}set_coins {ctx.author.mention} 100\n"
" {prefix}set_coins {ctx.author.mention} -100".format(
prefix=ctx.best_prefix,
ctx=ctx
)
)

View File

@@ -0,0 +1,163 @@
import difflib
import discord
from cmdClient.lib import SafeCancellation
from wards import guild_admin, guild_moderator
from settings import UserInputError, GuildSettings
from utils.lib import prop_tabulate
import utils.ctx_addons # noqa
from .module import module
# Pages of configuration categories to display
cat_pages = {
'Administration': ('Meta', 'Guild Roles', 'New Members'),
'Moderation': ('Moderation', 'Video Channels'),
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
'Study Rooms': ('Rented Rooms', 'Scheduled Sessions'),
}
# Descriptions of each configuration category
descriptions = {
}
@module.cmd("config",
desc="View and modify the server settings.",
flags=('add', 'remove'),
group="Guild Configuration")
@guild_moderator()
async def cmd_config(ctx, flags):
"""
Usage``:
{prefix}config
{prefix}config info
{prefix}config <setting>
{prefix}config <setting> <value>
Description:
Display the server configuration panel, and view/modify the server settings.
Use `{prefix}config` to see the settings with their current values, or `{prefix}config info` to \
show brief descriptions instead.
Use `{prefix}config <setting>` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \
including the possible values.
Finally, use `{prefix}config <setting> <value>` to set the setting to the given value.
To unset a setting, or set it to the default, use `{prefix}config <setting> None`.
Additional usage for settings which accept a list of values:
`{prefix}config <setting> <value1>, <value2>, ...`
`{prefix}config <setting> --add <value1>, <value2>, ...`
`{prefix}config <setting> --remove <value1>, <value2>, ...`
Note that the first form *overwrites* the setting completely,\
while the second two will only *add* and *remove* values, respectively.
Examples``:
{prefix}config event_log
{prefix}config event_log {ctx.ch.name}
{prefix}config autoroles Member, Level 0, Level 10
{prefix}config autoroles --remove Level 10
"""
# Cache and map some info for faster access
setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()}
if not ctx.args or ctx.args.lower() in ('info', 'help'):
# Fill the setting cats
cats = {}
for setting in GuildSettings.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(ctx.guild.id).summary if not ctx.args else setting.desc
for setting in cat
}
# TODO: Add cat description here
sections[catname] = prop_tabulate(*zip(*catprops.items()))
# Put the cats on the correct pages
pages = []
for page_name, cat_names in cat_pages.items():
page = {
cat_name: sections[cat_name] for cat_name in cat_names if cat_name in sections
}
if page:
embed = discord.Embed(
colour=discord.Colour.orange(),
title=page_name,
description=(
"View brief setting descriptions with `{prefix}config info`.\n"
"Use e.g. `{prefix}config event_log` to see more details.\n"
"Modify a setting with e.g. `{prefix}config event_log {ctx.ch.name}`.\n"
"See the [Online Tutorial]({tutorial}) for a complete setup guide.".format(
prefix=ctx.best_prefix,
ctx=ctx,
tutorial="https://discord.studylions.com/tutorial"
)
)
)
for name, value in page.items():
embed.add_field(name=name, value=value, inline=False)
pages.append(embed)
if len(pages) > 1:
[
embed.set_footer(text="Page {} of {}".format(i+1, len(pages)))
for i, embed in enumerate(pages)
]
await ctx.pager(pages)
elif pages:
await ctx.reply(embed=pages[0])
else:
await ctx.reply("No configuration options set up yet!")
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 `{}config info` to see all the server 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(ctx.guild.id).widget(ctx, flags=flags)
else:
# config <setting> <value>
# Ignoring the write ward currently and just enforcing admin
# Check the write ward
# if not await setting.write_ward.run(ctx):
# raise SafeCancellation(setting.write_ward.msg)
if not await guild_admin.run(ctx):
raise SafeCancellation("You need to be a server admin to modify settings!")
# Attempt to set config setting
try:
parsed = await setting.parse(ctx.guild.id, 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(ctx.guild.id).success_response),
colour=discord.Colour.green()
))

View File

@@ -0,0 +1,4 @@
from LionModule import LionModule
module = LionModule("Guild_Admin")

View File

@@ -0,0 +1,3 @@
from . import settings
from . import greetings
from . import roles

View File

@@ -0,0 +1,6 @@
from data import Table, RowTable
autoroles = Table('autoroles')
bot_autoroles = Table('bot_autoroles')
past_member_roles = Table('past_member_roles')

View File

@@ -0,0 +1,29 @@
import discord
from LionContext import LionContext as Context
from meta import client
from .settings import greeting_message, greeting_channel, returning_message
@client.add_after_event('member_join')
async def send_greetings(client, member):
guild = member.guild
returning = bool(client.data.lions.fetch((guild.id, member.id)))
# Handle greeting message
channel = greeting_channel.get(guild.id).value
if channel is not None:
if channel == greeting_channel.DMCHANNEL:
channel = member
ctx = Context(client, guild=guild, author=member)
if returning:
args = returning_message.get(guild.id).args(ctx)
else:
args = greeting_message.get(guild.id).args(ctx)
try:
await channel.send(**args)
except discord.HTTPException:
pass

View File

@@ -0,0 +1,115 @@
import asyncio
import discord
from collections import defaultdict
from meta import client
from core import Lion
from settings import GuildSettings
from .settings import autoroles, bot_autoroles, role_persistence
from .data import past_member_roles
# Locks to avoid storing the roles while adding them
# The locking is cautious, leaving data unchanged upon collision
locks = defaultdict(asyncio.Lock)
@client.add_after_event('member_join')
async def join_role_tracker(client, member):
"""
Add autoroles or saved roles as needed.
"""
guild = member.guild
if not guild.me.guild_permissions.manage_roles:
# We can't manage the roles here, don't try to give/restore the member roles
return
async with locks[(guild.id, member.id)]:
if role_persistence.get(guild.id).value and client.data.lions.fetch((guild.id, member.id)):
# Lookup stored roles
role_rows = past_member_roles.select_where(
guildid=guild.id,
userid=member.id
)
# Identify roles from roleids
roles = (guild.get_role(row['roleid']) for row in role_rows)
# Remove non-existent roles
roles = (role for role in roles if role is not None)
# Remove roles the client can't add
roles = [role for role in roles if role < guild.me.top_role]
if roles:
try:
await member.add_roles(
*roles,
reason="Restoring saved roles.",
)
except discord.HTTPException:
# This shouldn't ususally happen, but there are valid cases where it can
# E.g. the user left while we were restoring their roles
pass
# Event log!
GuildSettings(guild.id).event_log.log(
"Restored the following roles for returning member {}:\n{}".format(
member.mention,
', '.join(role.mention for role in roles)
),
title="Saved roles restored"
)
else:
# Add autoroles
roles = bot_autoroles.get(guild.id).value if member.bot else autoroles.get(guild.id).value
# Remove roles the client can't add
roles = [role for role in roles if role < guild.me.top_role]
if roles:
try:
await member.add_roles(
*roles,
reason="Adding autoroles.",
)
except discord.HTTPException:
# This shouldn't ususally happen, but there are valid cases where it can
# E.g. the user left while we were adding autoroles
pass
# Event log!
GuildSettings(guild.id).event_log.log(
"Gave {} the guild autoroles:\n{}".format(
member.mention,
', '.join(role.mention for role in roles)
),
titles="Autoroles added"
)
@client.add_after_event('member_remove')
async def left_role_tracker(client, member):
"""
Delete and re-store member roles when they leave the server.
"""
if (member.guild.id, member.id) in locks and locks[(member.guild.id, member.id)].locked():
# Currently processing a join event
# Which means the member left while we were adding their roles
# Cautiously return, not modifying the saved role data
return
# Delete existing member roles for this user
# NOTE: Not concurrency-safe
past_member_roles.delete_where(
guildid=member.guild.id,
userid=member.id,
)
if role_persistence.get(member.guild.id).value:
# Make sure the user has an associated lion, so we can detect when they rejoin
Lion.fetch(member.guild.id, member.id)
# Then insert the current member roles
values = [
(member.guild.id, member.id, role.id)
for role in member.roles
if not role.is_bot_managed() and not role.is_integration() and not role.is_default()
]
if values:
past_member_roles.insert_many(
*values,
insert_keys=('guildid', 'userid', 'roleid')
)

View File

@@ -0,0 +1,303 @@
import datetime
import discord
import settings
from settings import GuildSettings, GuildSetting
import settings.setting_types as stypes
from wards import guild_admin
from .data import autoroles, bot_autoroles
@GuildSettings.attach_setting
class greeting_channel(stypes.Channel, GuildSetting):
"""
Setting describing the destination of the greeting message.
Extended to support the following special values, with input and output supported.
Data `None` corresponds to `Off`.
Data `1` corresponds to `DM`.
"""
DMCHANNEL = object()
category = "New Members"
attr_name = 'greeting_channel'
_data_column = 'greeting_channel'
display_name = "welcome_channel"
desc = "Channel to send the welcome message in"
long_desc = (
"Channel to post the `welcome_message` in when a new user joins the server. "
"Accepts `DM` to indicate the welcome should be sent via direct message."
)
_accepts = (
"Text Channel name/id/mention, or `DM`, or `None` to disable."
)
_chan_type = discord.ChannelType.text
@classmethod
def _data_to_value(cls, id, data, **kwargs):
if data is None:
return None
elif data == 1:
return cls.DMCHANNEL
else:
return super()._data_to_value(id, data, **kwargs)
@classmethod
def _data_from_value(cls, id, value, **kwargs):
if value is None:
return None
elif value == cls.DMCHANNEL:
return 1
else:
return super()._data_from_value(id, value, **kwargs)
@classmethod
async def _parse_userstr(cls, ctx, id, userstr, **kwargs):
lower = userstr.lower()
if lower in ('0', 'none', 'off'):
return None
elif lower == 'dm':
return 1
else:
return await super()._parse_userstr(ctx, id, userstr, **kwargs)
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "Off"
elif data == 1:
return "DM"
else:
return "<#{}>".format(data)
@property
def success_response(self):
value = self.value
if not value:
return "Welcome messages are disabled."
elif value == self.DMCHANNEL:
return "Welcome messages will be sent via direct message."
else:
return "Welcome messages will be posted in {}".format(self.formatted)
@GuildSettings.attach_setting
class greeting_message(stypes.Message, GuildSetting):
category = "New Members"
attr_name = 'greeting_message'
_data_column = 'greeting_message'
display_name = 'welcome_message'
desc = "Welcome message sent to welcome new members."
long_desc = (
"Message to send to the configured `welcome_channel` when a member joins the server for the first time."
)
_default = r"""
{
"embed": {
"title": "Welcome!",
"thumbnail": {"url": "{guild_icon}"},
"description": "Hi {mention}!\nWelcome to **{guild_name}**! You are the **{member_count}**th member.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
"color": 15695665
}
}
"""
_substitution_desc = {
'{mention}': "Mention the new member.",
'{user_name}': "Username of the new member.",
'{user_avatar}': "Avatar of the new member.",
'{guild_name}': "Name of this server.",
'{guild_icon}': "Server icon url.",
'{member_count}': "Number of members in the server.",
'{studying_count}': "Number of current voice channel members.",
}
def substitution_keys(self, ctx, **kwargs):
return {
'{mention}': ctx.author.mention,
'{user_name}': ctx.author.name,
'{user_avatar}': str(ctx.author.avatar_url),
'{guild_name}': ctx.guild.name,
'{guild_icon}': str(ctx.guild.icon_url),
'{member_count}': str(len(ctx.guild.members)),
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members]))
}
@property
def success_response(self):
return "The welcome message has been set!"
@GuildSettings.attach_setting
class returning_message(stypes.Message, GuildSetting):
category = "New Members"
attr_name = 'returning_message'
_data_column = 'returning_message'
display_name = 'returning_message'
desc = "Welcome message sent to returning members."
long_desc = (
"Message to send to the configured `welcome_channel` when a member returns to the server."
)
_default = r"""
{
"embed": {
"title": "Welcome Back {user_name}!",
"thumbnail": {"url": "{guild_icon}"},
"description": "Welcome back to **{guild_name}**!\nYou last studied with us <t:{last_time}:R>.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
"color": 15695665
}
}
"""
_substitution_desc = {
'{mention}': "Mention the returning member.",
'{user_name}': "Username of the member.",
'{user_avatar}': "Avatar of the member.",
'{guild_name}': "Name of this server.",
'{guild_icon}': "Server icon url.",
'{member_count}': "Number of members in the server.",
'{studying_count}': "Number of current voice channel members.",
'{last_time}': "Unix timestamp of the last time the member studied.",
}
def substitution_keys(self, ctx, **kwargs):
return {
'{mention}': ctx.author.mention,
'{user_name}': ctx.author.name,
'{user_avatar}': str(ctx.author.avatar_url),
'{guild_name}': ctx.guild.name,
'{guild_icon}': str(ctx.guild.icon_url),
'{member_count}': str(len(ctx.guild.members)),
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])),
'{last_time}': int(ctx.alion.data._timestamp.replace(tzinfo=datetime.timezone.utc).timestamp()),
}
@property
def success_response(self):
return "The returning message has been set!"
@GuildSettings.attach_setting
class starting_funds(stypes.Integer, GuildSetting):
category = "New Members"
attr_name = 'starting_funds'
_data_column = 'starting_funds'
display_name = 'starting_funds'
desc = "Coins given when a user first joins."
long_desc = (
"Members will be given this number of coins the first time they join the server."
)
_default = 1000
@property
def success_response(self):
return "Members will be given `{}` coins when they first join the server.".format(self.formatted)
@GuildSettings.attach_setting
class autoroles(stypes.RoleList, settings.ListData, settings.Setting):
category = "New Members"
write_ward = guild_admin
attr_name = 'autoroles'
_table_interface = autoroles
_id_column = 'guildid'
_data_column = 'roleid'
display_name = "autoroles"
desc = "Roles to give automatically to new members."
_force_unique = True
long_desc = (
"These roles will be given automatically to users when they join the server. "
"If `role_persistence` is enabled, the roles will only be given the first time a user joins the server."
)
# Flat cache, no need to expire
_cache = {}
@property
def success_response(self):
if self.value:
return "New members will be given the following roles:\n{}".format(self.formatted)
else:
return "New members will not automatically be given any roles."
@GuildSettings.attach_setting
class bot_autoroles(stypes.RoleList, settings.ListData, settings.Setting):
category = "New Members"
write_ward = guild_admin
attr_name = 'bot_autoroles'
_table_interface = bot_autoroles
_id_column = 'guildid'
_data_column = 'roleid'
display_name = "bot_autoroles"
desc = "Roles to give automatically to new bots."
_force_unique = True
long_desc = (
"These roles will be given automatically to bots when they join the server. "
"If `role_persistence` is enabled, the roles will only be given the first time a bot joins the server."
)
# Flat cache, no need to expire
_cache = {}
@property
def success_response(self):
if self.value:
return "New bots will be given the following roles:\n{}".format(self.formatted)
else:
return "New bots will not automatically be given any roles."
@GuildSettings.attach_setting
class role_persistence(stypes.Boolean, GuildSetting):
category = "New Members"
attr_name = "role_persistence"
_data_column = 'persist_roles'
display_name = "role_persistence"
desc = "Whether to remember member roles when they leave the server."
_outputs = {True: "Enabled", False: "Disabled"}
_default = True
long_desc = (
"When enabled, restores member roles when they rejoin the server.\n"
"This enables profile roles and purchased roles, such as field of study and colour roles, "
"as well as moderation roles, "
"such as the studyban and mute roles, to persist even when a member leaves and rejoins.\n"
"Note: Members who leave while this is disabled will not have their roles restored."
)
@property
def success_response(self):
if self.value:
return "Roles will now be restored when a member rejoins."
else:
return "Member roles will no longer be saved or restored."

View File

@@ -0,0 +1,6 @@
from .module import module
from . import data
from . import settings
from . import tracker
from . import command

View File

@@ -0,0 +1,943 @@
import asyncio
import discord
from discord import PartialEmoji
from cmdClient.lib import ResponseTimedOut, UserCancelled
from wards import guild_admin
from settings import UserInputError
from utils.lib import tick, cross
from .module import module
from .tracker import ReactionRoleMessage
from .data import reaction_role_reactions, reaction_role_messages
from . import settings
example_emoji = "🧮"
example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine"
def _parse_messageref(ctx):
"""
Parse a message reference from the context message and return it.
Removes the parsed string from `ctx.args` if applicable.
Supports the following reference types, in precedence order:
- A Discord message reply reference.
- A message link.
- A message id.
Returns: (channelid, messageid)
`messageid` will be `None` if a valid reference was not found.
`channelid` will be `None` if the message was provided by pure id.
"""
target_id = None
target_chid = None
if ctx.msg.reference:
# True message reference extract message and return
target_id = ctx.msg.reference.message_id
target_chid = ctx.msg.reference.channel_id
elif ctx.args:
# Parse the first word of the message arguments
splits = ctx.args.split(maxsplit=1)
maybe_target = splits[0]
# Expect a message id or message link
if maybe_target.isdigit():
# Assume it is a message id
target_id = int(maybe_target)
elif '/' in maybe_target:
# Assume it is a link
# Split out the channelid and messageid, if possible
link_splits = maybe_target.rsplit('/', maxsplit=2)
if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit():
target_id = int(link_splits[-1])
target_chid = int(link_splits[-2])
# If we found a target id, truncate the arguments
if target_id is not None:
if len(splits) > 1:
ctx.args = splits[1].strip()
else:
ctx.args = ""
else:
# Last-ditch attempt, see if the argument could be a stored reaction
maybe_emoji = maybe_target.strip(',')
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
emojis = {reaction.emoji: message for message in messages for reaction in message.reactions}
emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis}
emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id}
result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map)
if result and result in emojis:
message = emojis[result]
target_id = message.messageid
target_chid = message.data.channelid
# Return the message reference
return (target_chid, target_id)
def _parse_emoji(emoji_str, name_map, id_map):
"""
Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids.
"""
emoji = None
if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str):
# The string is pure unicode, we assume built in emoji
emoji = PartialEmoji(name=emoji_str)
elif emoji_str.lower() in name_map:
emoji = name_map[emoji_str.lower()]
elif emoji_str.isdigit() and int(emoji_str) in id_map:
emoji = id_map[int(emoji_str)]
else:
# Attempt to parse as custom emoji
# Accept custom emoji provided in the full form
emoji_split = emoji_str.strip('<>:').split(':')
if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit():
emoji_id = int(emoji_split[-1])
emoji_name = emoji_split[-2]
emoji_animated = emoji_split[0] == 'a'
emoji = PartialEmoji(
name=emoji_name,
id=emoji_id,
animated=emoji_animated
)
return emoji
async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None):
"""
Asks the author the provided question in an embed, and provides check/cross reactions for answering.
"""
embed = discord.Embed(
colour=discord.Colour.orange(),
description=question
)
out_msg = await ctx.reply(embed=embed)
# Wait for a tick/cross
asyncio.create_task(out_msg.add_reaction(tick))
asyncio.create_task(out_msg.add_reaction(cross))
def check(reaction, user):
result = True
result = result and reaction.message == out_msg
result = result and user == ctx.author
result = result and (reaction.emoji == tick or reaction.emoji == cross)
return result
try:
reaction, _ = await ctx.client.wait_for(
'reaction_add',
check=check,
timeout=120
)
except asyncio.TimeoutError:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description=timeout_msg or "Prompt timed out."
)
)
except discord.HTTPException:
pass
raise ResponseTimedOut from None
if reaction.emoji == cross:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description=cancel_msg or "Cancelled."
)
)
except discord.HTTPException:
pass
raise UserCancelled from None
try:
await out_msg.delete()
except discord.HTTPException:
pass
return True
_message_setting_flags = {
'removable': settings.removable,
'maximum': settings.maximum,
'required_role': settings.required_role,
'log': settings.log,
'refunds': settings.refunds,
'default_price': settings.default_price,
}
_reaction_setting_flags = {
'price': settings.price,
'duration': settings.duration
}
@module.cmd(
"reactionroles",
group="Guild Configuration",
desc="Create and configure reaction role messages.",
aliases=('rroles',),
flags=(
'delete', 'remove==',
'enable', 'disable',
'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=',
'price=', 'duration=='
)
)
@guild_admin()
async def cmd_reactionroles(ctx, flags):
"""
Usage``:
{prefix}rroles
{prefix}rroles [enable|disable|delete] msglink
{prefix}rroles msglink [emoji1 role1, emoji2 role2, ...]
{prefix}rroles msglink --remove emoji1, emoji2, ...
{prefix}rroles msglink --message_setting [value]
{prefix}rroles msglink emoji --reaction_setting [value]
Description:
Create and configure "reaction roles", i.e. roles obtainable by \
clicking reactions on a particular message.
`msglink` is the link or message id of the message with reactions.
`emoji` should be given as the emoji itself, or the name or id.
`role` may be given by name, mention, or id.
Getting started:
First choose the message you want to add reaction roles to, \
and copy the link or message id for that message. \
Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \
and follow the prompts.
For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead.
Editing reaction roles:
Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...`
Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...`
Examples``:
{prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine
{prefix}rroles disable {ctx.msg.id}
PAGEBREAK:
Page 2
Advanced configuration:
Type `{prefix}rroles link` again to view the advanced setting window, \
and use `{prefix}rroles link --setting value` to modify the settings. \
See below for descriptions of each message setting.
For example to disable event logging, run `{prefix}rroles link --log off`.
For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`.
*(!) Replace `setting` with one of the settings below!*
Message Settings::
maximum: Maximum number of roles obtainable from this message.
log: Whether to log reaction role usage into the event log.
removable: Whether the reactions roles can be remove by unreacting.
refunds: Whether to refund the role price when removing the role.
default_price: The default price of each role on this message.
required_role: The role required to use these reactions roles.
Reaction Settings::
price: The price of this reaction role. (May be negative for a reward.)
tduration: How long this role will last after being selected or bought.
Configuration Examples``:
{prefix}rroles {ctx.msg.id} --maximum 5
{prefix}rroles {ctx.msg.id} --default_price 20
{prefix}rroles {ctx.msg.id} --required_role None
{prefix}rroles {ctx.msg.id} 🧮 --price 1024
{prefix}rroles {ctx.msg.id} 🧮 --duration 7 days
"""
if not ctx.args:
# No target message provided, list the current reaction messages
# Or give a brief guide if there are no current reaction messages
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
if guild_message_rows:
# List messages
# First get the list of reaction role messages in the guild
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
# Sort them by channelid and messageid
messages.sort(key=lambda m: (m.data.channelid, m.messageid))
# Build the message description strings
message_strings = []
for message in messages:
header = (
"`{}` in <#{}> ([Click to jump]({})){}".format(
message.messageid,
message.data.channelid,
message.message_link,
" (disabled)" if not message.enabled else ""
)
)
role_strings = [
"{} <@&{}>".format(reaction.emoji, reaction.data.roleid)
for reaction in message.reactions
]
role_string = '\n'.join(role_strings) or "No reaction roles!"
message_strings.append("{}\n{}".format(header, role_string))
pages = []
page = []
page_len = 0
page_chars = 0
i = 0
while i < len(message_strings):
message_string = message_strings[i]
chars = len(message_string)
lines = len(message_string.splitlines())
if (page and lines + page_len > 20) or (chars + page_chars > 2000):
pages.append('\n\n'.join(page))
page = []
page_len = 0
page_chars = 0
else:
page.append(message_string)
page_len += lines
page_chars += chars
i += 1
if page:
pages.append('\n\n'.join(page))
page_count = len(pages)
title = "Reaction Roles in {}".format(ctx.guild.name)
embeds = [
discord.Embed(
colour=discord.Colour.orange(),
description=page,
title=title
)
for page in pages
]
if page_count > 1:
[embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)]
await ctx.pager(embeds)
else:
# Send a setup guide
embed = discord.Embed(
title="No Reaction Roles set up!",
description=(
"To setup reaction roles, first copy the link or message id of the message you want to "
"add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, "
"and follow the prompts.\n"
"See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix)
),
colour=discord.Colour.orange()
)
await ctx.reply(embed=embed)
return
# Extract first word, look for a subcommand
splits = ctx.args.split(maxsplit=1)
subcmd = splits[0].lower()
if subcmd in ('enable', 'disable', 'delete'):
# Truncate arguments and extract target
if len(splits) > 1:
ctx.args = splits[1]
target_chid, target_id = _parse_messageref(ctx)
else:
target_chid = None
target_id = None
ctx.args = ''
# Handle subcommand special cases
if subcmd == 'enable':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to enable!\n"
"**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm enabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to enable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles enabled.",
cancel_msg="User cancelled, no reaction roles enabled."
)
reaction_role_messages.update_where(
{'enabled': True},
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been enabled.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles!\n"
"Run the command again without `enable` to assign reaction roles."
)
else:
# We have a valid target
if target.enabled:
await ctx.error_reply(
"This message is already enabled!"
)
else:
target.enabled = True
await ctx.embed_reply(
"The message has been enabled!"
)
elif subcmd == 'disable':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to disable!\n"
"**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm disabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to disable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles disabled.",
cancel_msg="User cancelled, no reaction roles disabled."
)
reaction_role_messages.update_where(
{'enabled': False},
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been disabled.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles! Nothing to disable."
)
else:
# We have a valid target
if not target.enabled:
await ctx.error_reply(
"This message is already disabled!"
)
else:
target.enabled = False
await ctx.embed_reply(
"The message has been disabled!"
)
elif subcmd == 'delete':
if ctx.args and not target_id:
await ctx.error_reply(
"Couldn't find the message to remove!\n"
"**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix)
)
elif not target_id:
# Confirm disabling of all reaction messages
await reaction_ask(
ctx,
"Are you sure you want to remove all reaction role messages in this server?",
timeout_msg="Prompt timed out, no messages removed.",
cancel_msg="User cancelled, no messages removed."
)
reaction_role_messages.delete_where(
guildid=ctx.guild.id
)
await ctx.embed_reply(
"All reaction role messages have been removed.",
colour=discord.Colour.green(),
)
else:
# Fetch the target
target = ReactionRoleMessage.fetch(target_id)
if target is None:
await ctx.error_reply(
"This message doesn't have any reaction roles! Nothing to remove."
)
else:
# We have a valid target
target.delete()
await ctx.embed_reply(
"The message has been removed and is no longer a reaction role message."
)
return
else:
# Just extract target
target_chid, target_id = _parse_messageref(ctx)
# Handle target parsing issue
if target_id is None:
return await ctx.error_reply(
"Couldn't parse `{}` as a message id or message link!\n"
"See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix)
)
# Get the associated ReactionRoleMessage, if it exists
target = ReactionRoleMessage.fetch(target_id)
# Get the target message
if target:
message = await target.fetch_message()
if not message:
# TODO: Consider offering some sort of `move` option here.
await ctx.error_reply(
"This reaction role message no longer exists!\n"
"Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid)
)
else:
message = None
if target_chid:
channel = ctx.guild.get_channel(target_chid)
if not channel:
await ctx.error_reply(
"The provided channel no longer exists!"
)
elif not isinstance(channel, discord.TextChannel):
await ctx.error_reply(
"The provided channel is not a text channel!"
)
else:
message = await channel.fetch_message(target_id)
if not message:
await ctx.error_reply(
"Couldn't find the specified message in {}!".format(channel.mention)
)
else:
out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id))
message = await ctx.find_message(target_id)
try:
await out_msg.delete()
except discord.HTTPException:
pass
if not message:
await ctx.error_reply(
"Couldn't find the message `{}`!".format(target_id)
)
if not message:
return
# Handle the `remove` flag specially
# In particular, all other flags are ignored
if flags['remove']:
if not target:
await ctx.error_reply(
"The specified message has no reaction roles! Nothing to remove."
)
else:
# Parse emojis and remove from target
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis}
emoji_id_map = {emoji.id: emoji for emoji in target_emojis}
items = [item.strip() for item in flags['remove'].split(',')]
to_remove = [] # List of reactions to remove
for emoji_str in items:
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str)
)
if emoji not in target_emojis:
return await ctx.error_reply(
"{} is not a reaction role for this message!".format(emoji)
)
to_remove.append(target_emojis[emoji])
# Delete reactions from data
description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove)
reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove])
target.refresh()
# Ack
embed = discord.Embed(
colour=discord.Colour.green(),
title="Reaction Roles deactivated",
description=description
)
await ctx.reply(embed=embed)
return
# Any remaining arguments should be emoji specifications with optional role
# Parse these now
given_emojis = {} # Map PartialEmoji -> Optional[Role]
existing_emojis = set() # Set of existing reaction emoji identifiers
if ctx.args:
# First build the list of custom emojis we can accept by name
# We do this by reverse precedence, so the highest priority emojis are added last
custom_emojis = []
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
if target:
custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target
custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message
# Filter out the built in emojis and those without a name
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
# Build the maps to lookup provided custom emojis
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
# Now parse the provided emojis
# Assume that all-unicode strings are built-in emojis
# We can't assume much else unless we have a list of such emojis
splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split)
splits = (split.split(maxsplit=1) for split in splits if split)
arg_emoji_strings = {
split[0]: split[1] if len(split) > 1 else None
for split in splits
} # emoji_str -> Optional[role_str]
arg_emoji_map = {}
for emoji_str, role_str in arg_emoji_strings.items():
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji!".format(emoji_str)
)
else:
arg_emoji_map[emoji] = role_str
# Final pass extracts roles
# If any new emojis were provided, their roles should be specified, we enforce this during role parsing
# First collect the existing emoji strings
if target:
for reaction in target.reactions:
emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id
existing_emojis.add(emoji_id)
# Now parse and assign the roles, building the final map
for emoji, role_str in arg_emoji_map.items():
emoji_id = emoji.name if emoji.id is None else emoji.id
role = None
if role_str:
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
elif emoji_id not in existing_emojis:
return await ctx.error_reply(
"New emoji {} was given without an associated role!".format(emoji)
)
given_emojis[emoji] = role
# Next manage target creation or emoji editing, if required
if target is None:
# Reaction message creation wizard
# Confirm that they want to create a new reaction role message.
await reaction_ask(
ctx,
question="Do you want to set up new reaction roles for [this message]({})?".format(
message.jump_url
),
timeout_msg="Prompt timed out, no reaction roles created.",
cancel_msg="Reaction Role creation cancelled."
)
# Continue with creation
# Obtain emojis if not already provided
if not given_emojis:
# Prompt for the initial emojis
embed = discord.Embed(
colour=discord.Colour.orange(),
title="What reaction roles would you like to add?",
description=(
"Please now type the reaction roles you would like to add "
"in the form `emoji role`, where `role` is given by partial name or id. For example:"
"```{}```".format(example_str)
)
)
out_msg = await ctx.reply(embed=embed)
# Wait for a response
def check(msg):
return msg.author == ctx.author and msg.channel == ctx.ch and msg.content
try:
reply = await ctx.client.wait_for('message', check=check, timeout=300)
except asyncio.TimeoutError:
try:
await out_msg.edit(
embed=discord.Embed(
colour=discord.Colour.red(),
description="Prompt timed out, no reaction roles created."
)
)
except discord.HTTPException:
pass
return
rolestrs = reply.content
try:
await reply.delete()
except discord.HTTPException:
pass
# Attempt to parse the emojis
# First build the list of custom emojis we can accept by name
custom_emojis = []
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
custom_emojis.extend(
r.emoji for r in message.reactions if r.custom_emoji
) # Actual reactions on the message
# Filter out the built in emojis and those without a name
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
# Build the maps to lookup provided custom emojis
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
# Now parse the provided emojis
# Assume that all-unicode strings are built-in emojis
# We can't assume much else unless we have a list of such emojis
splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split)
splits = (split.split(maxsplit=1) for split in splits if split)
arg_emoji_strings = {
split[0]: split[1] if len(split) > 1 else None
for split in splits
} # emoji_str -> Optional[role_str]
# Check all the emojis have roles associated
for emoji_str, role_str in arg_emoji_strings.items():
if role_str is None:
return await ctx.error_reply(
"No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str)
)
# Parse the provided roles and emojis
for emoji_str, role_str in arg_emoji_strings.items():
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
if emoji is None:
return await ctx.error_reply(
"Couldn't parse `{}` as an emoji!".format(emoji_str)
)
else:
given_emojis[emoji] = await ctx.find_role(
role_str,
create=True,
interactive=True,
allow_notfound=False
)
if len(given_emojis) > 20:
return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.")
# Create the ReactionRoleMessage
target = ReactionRoleMessage.create(
message.id,
message.guild.id,
message.channel.id
)
# Insert the reaction data directly
reaction_role_reactions.insert_many(
*((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()),
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
)
# Refresh the message to pick up the new reactions
target.refresh()
# Add the reactions to the message, if possible
existing_reactions = set(
reaction.emoji if not reaction.custom_emoji else
(reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id)
for reaction in message.reactions
)
missing = [
reaction.emoji for reaction in target.reactions
if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions
]
if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id):
# We can add the missing emojis
for emoji in missing:
try:
await message.add_reaction(emoji)
except discord.HTTPException:
break
else:
missing = []
# Ack the creation
ack_msg = "Created `{}` new reaction roles on [this message]({})!".format(
len(target.reactions),
target.message_link
)
if missing:
ack_msg += "\nPlease add the missing reactions to the message!"
await ctx.embed_reply(
ack_msg
)
elif given_emojis:
# Update the target reactions
# Create a map of the emojis that need to be added or updated
needs_update = {
emoji: role for emoji, role in given_emojis.items() if role
}
# Fetch the existing target emojis to split the roles into inserts and updates
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
# Handle the new roles
insert_targets = {
emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis
}
if insert_targets:
if len(insert_targets) + len(target_emojis) > 20:
return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!")
reaction_role_reactions.insert_many(
*(
(message.id, role.id, emoji.name, emoji.id, emoji.animated)
for emoji, role in insert_targets.items()
),
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
)
# Handle the updated roles
update_targets = {
target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis
}
if update_targets:
reaction_role_reactions.update_many(
*((role.id, reaction.reactionid) for reaction, role in update_targets.items()),
set_keys=('roleid',),
where_keys=('reactionid',),
)
# Finally, refresh to load the new reactions
target.refresh()
# Now that the target is created/updated, all the provided emojis should be reactions
given_reactions = []
if given_emojis:
# Make a map of the existing reactions
existing_reactions = {
reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction
for reaction in target.reactions
}
given_reactions = [
existing_reactions[emoji.name if emoji.id is None else emoji.id]
for emoji in given_emojis
]
# Handle message setting updates
update_lines = [] # Setting update lines to display
update_columns = {} # Message data columns to update
for flag in _message_setting_flags:
if flags[flag]:
setting_class = _message_setting_flags[flag]
try:
setting = await setting_class.parse(target.messageid, ctx, flags[flag])
except UserInputError as e:
return await ctx.error_reply(
"{} {}\nNo settings were modified.".format(cross, e.msg),
title="Couldn't save settings!"
)
else:
update_lines.append(
"{} {}".format(tick, setting.success_response)
)
update_columns[setting._data_column] = setting.data
if update_columns:
# First write the data
reaction_role_messages.update_where(
update_columns,
messageid=target.messageid
)
# Then ack the setting update
if len(update_lines) > 1:
embed = discord.Embed(
colour=discord.Colour.green(),
title="Reaction Role message settings updated!",
description='\n'.join(update_lines)
)
else:
embed = discord.Embed(
colour=discord.Colour.green(),
description=update_lines[0]
)
await ctx.reply(embed=embed)
# Handle reaction setting updates
update_lines = [] # Setting update lines to display
update_columns = {} # Message data columns to update, for all given reactions
reactions = given_reactions or target.reactions
for flag in _reaction_setting_flags:
for reaction in reactions:
if flags[flag]:
setting_class = _reaction_setting_flags[flag]
try:
setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag])
except UserInputError as e:
return await ctx.error_reply(
"{} {}\nNo reaction roles were modified.".format(cross, e.msg),
title="Couldn't save reaction role settings!",
)
else:
update_lines.append(
setting.success_response.format(reaction=reaction)
)
update_columns[setting._data_column] = setting.data
if update_columns:
# First write the data
reaction_role_reactions.update_where(
update_columns,
reactionid=[reaction.reactionid for reaction in reactions]
)
# Then ack the setting update
if len(update_lines) > 1:
blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)]
embeds = [
discord.Embed(
colour=discord.Colour.green(),
title="Reaction Role settings updated!",
description=block
) for block in blocks
]
await ctx.pager(embeds)
else:
embed = discord.Embed(
colour=discord.Colour.green(),
description=update_lines[0]
)
await ctx.reply(embed=embed)
# Show the reaction role message summary
# Build the reaction fields
reaction_fields = [] # List of tuples (name, value)
for reaction in target.reactions:
reaction_fields.append(
(
"{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''),
"<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated())
)
)
# Build the final setting pages
description = (
"{settings_table}\n"
"To update a message setting: `{prefix}rroles messageid --setting value`\n"
"To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n"
"See examples and more usage information with `{prefix}help rroles`.\n"
"**(!) Replace the `setting` with one of the settings on this page.**\n"
).format(
prefix=ctx.best_prefix,
settings_table=target.settings.tabulated()
)
field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)]
page_count = len(field_blocks)
embeds = []
for i, block in enumerate(field_blocks):
title = "Reaction role settings for message id `{}`".format(target.messageid)
embed = discord.Embed(
title=title,
description=description
).set_author(
name="Click to jump to message",
url=target.message_link
)
for name, value in block:
embed.add_field(name=name, value=value)
if page_count > 1:
embed.set_footer(text="Page {} of {}".format(i+1, page_count))
embeds.append(embed)
# Finally, send the reaction role information
await ctx.pager(embeds)

View File

@@ -0,0 +1,22 @@
from data import Table, RowTable
reaction_role_messages = RowTable(
'reaction_role_messages',
('messageid', 'guildid', 'channelid',
'enabled',
'required_role', 'allow_deselction',
'max_obtainable', 'allow_refunds',
'event_log'),
'messageid'
)
reaction_role_reactions = RowTable(
'reaction_role_reactions',
('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'),
'reactionid'
)
reaction_role_expiring = Table('reaction_role_expiring')

View File

@@ -0,0 +1,172 @@
import logging
import traceback
import asyncio
import discord
from meta import client
from utils.lib import utc_now
from settings import GuildSettings
from .module import module
from .data import reaction_role_expiring
_expiring = {}
_wakeup_event = asyncio.Event()
# TODO: More efficient data structure for min optimisation, e.g. pre-sorted with bisection insert
# Public expiry interface
def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None):
"""
Schedule expiry of the given role for the given member at the given time.
This will also cancel any existing expiry for this member, role pair.
"""
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid,
)
reaction_role_expiring.insert(
guildid=guildid,
userid=userid,
roleid=roleid,
expiry=expiry,
reactionid=reactionid
)
key = (guildid, userid, roleid)
_expiring[key] = expiry.timestamp()
_wakeup_event.set()
def cancel_expiry(*key):
"""
Cancel expiry for the given member and role, if it exists.
"""
guildid, userid, roleid = key
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid,
)
if _expiring.pop(key, None) is not None:
# Wakeup the expiry tracker for recalculation
_wakeup_event.set()
def _next():
"""
Calculate the next member, role pair to expire.
"""
if _expiring:
key, _ = min(_expiring.items(), key=lambda pair: pair[1])
return key
else:
return None
async def _expire(key):
"""
Execute reaction role expiry for the given member and role.
This removes the role and logs the removal if applicable.
If the user is no longer in the guild, it removes the role from the persistent roles instead.
"""
guildid, userid, roleid = key
guild = client.get_guild(guildid)
if guild:
role = guild.get_role(roleid)
if role:
member = guild.get_member(userid)
if member:
log = GuildSettings(guildid).event_log.log
if role in member.roles:
# Remove role from member, and log if applicable
try:
await member.remove_roles(
role,
atomic=True,
reason="Expiring temporary reaction role."
)
except discord.HTTPException:
log(
"Failed to remove expired reaction role {} from {}.".format(
role.mention,
member.mention
),
colour=discord.Colour.red(),
title="Could not remove expired Reaction Role!"
)
else:
log(
"Removing expired reaction role {} from {}.".format(
role.mention,
member.mention
),
title="Reaction Role expired!"
)
else:
# Remove role from stored persistent roles, if existent
client.data.past_member_roles.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid
)
reaction_role_expiring.delete_where(
guildid=guildid,
userid=userid,
roleid=roleid
)
async def _expiry_tracker(client):
"""
Track and launch role expiry.
"""
while True:
try:
key = _next()
diff = _expiring[key] - utc_now().timestamp() if key else None
await asyncio.wait_for(_wakeup_event.wait(), timeout=diff)
except asyncio.TimeoutError:
# Timeout means next doesn't exist or is ready to expire
if key and key in _expiring and _expiring[key] <= utc_now().timestamp() + 1:
_expiring.pop(key)
asyncio.create_task(_expire(key))
except Exception:
# This should be impossible, but catch and log anyway
client.log(
"Exception occurred while tracking reaction role expiry. Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="REACTION_ROLE_EXPIRY",
level=logging.ERROR
)
else:
# Wakeup event means that we should recalculate next
_wakeup_event.clear()
@module.launch_task
async def launch_expiry_tracker(client):
"""
Launch the role expiry tracker.
"""
asyncio.create_task(_expiry_tracker(client))
client.log("Reaction role expiry tracker launched.", context="REACTION_ROLE_EXPIRY")
@module.init_task
def load_expiring_roles(client):
"""
Initialise the expiring reaction role map, and attach it to the client.
"""
rows = reaction_role_expiring.select_where()
_expiring.clear()
_expiring.update({(row['guildid'], row['userid'], row['roleid']): row['expiry'].timestamp() for row in rows})
client.objects['expiring_reaction_roles'] = _expiring
if _expiring:
client.log(
"Loaded {} expiring reaction roles.".format(len(_expiring)),
context="REACTION_ROLE_EXPIRY"
)

View File

@@ -0,0 +1,3 @@
from LionModule import LionModule
module = LionModule("Reaction_Roles")

View File

@@ -0,0 +1,257 @@
from utils.lib import DotDict
from wards import guild_admin
from settings import ObjectSettings, ColumnData, Setting
import settings.setting_types as setting_types
from .data import reaction_role_messages, reaction_role_reactions
class RoleMessageSettings(ObjectSettings):
settings = DotDict()
class RoleMessageSetting(ColumnData, Setting):
_table_interface = reaction_role_messages
_id_column = 'messageid'
_create_row = False
write_ward = guild_admin
@RoleMessageSettings.attach_setting
class required_role(setting_types.Role, RoleMessageSetting):
attr_name = 'required_role'
_data_column = 'required_role'
display_name = "required_role"
desc = "Role required to use the reaction roles."
long_desc = (
"Members will be required to have the specified role to use the reactions on this message."
)
@property
def success_response(self):
if self.value:
return "Members need {} to use these reaction roles.".format(self.formatted)
else:
return "All members can now use these reaction roles."
@classmethod
def _get_guildid(cls, id: int, **kwargs):
return reaction_role_messages.fetch(id).guildid
@RoleMessageSettings.attach_setting
class removable(setting_types.Boolean, RoleMessageSetting):
attr_name = 'removable'
_data_column = 'removable'
display_name = "removable"
desc = "Whether the role is removable by deselecting the reaction."
long_desc = (
"If enabled, the role will be removed when the reaction is deselected."
)
_default = True
@property
def success_response(self):
if self.value:
return "Members will be able to remove roles by unreacting."
else:
return "Members will not be able to remove the reaction roles."
@RoleMessageSettings.attach_setting
class maximum(setting_types.Integer, RoleMessageSetting):
attr_name = 'maximum'
_data_column = 'maximum'
display_name = "maximum"
desc = "The maximum number of roles a member can get from this message."
long_desc = (
"The maximum number of roles that a member can get from this message. "
"They will be notified by DM if they attempt to add more.\n"
"The `removable` setting should generally be enabled with this setting."
)
accepts = "An integer number of roles, or `None` to remove the maximum."
_min = 0
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "No maximum!"
else:
return "`{}`".format(data)
@property
def success_response(self):
if self.value:
return "Members can get a maximum of `{}` roles from this message.".format(self.value)
else:
return "Members can now get all the roles from this mesage."
@RoleMessageSettings.attach_setting
class refunds(setting_types.Boolean, RoleMessageSetting):
attr_name = 'refunds'
_data_column = 'refunds'
display_name = "refunds"
desc = "Whether a user will be refunded when they deselect a role."
long_desc = (
"Whether to give the user a refund when they deselect a role by reaction. "
"This has no effect if `removable` is not enabled, or if the role removed has no cost."
)
_default = True
@property
def success_response(self):
if self.value:
return "Members will get a refund when they remove a role."
else:
return "Members will not get a refund when they remove a role."
@RoleMessageSettings.attach_setting
class default_price(setting_types.Integer, RoleMessageSetting):
attr_name = 'default_price'
_data_column = 'default_price'
display_name = "default_price"
desc = "Default price of reaction roles on this message."
long_desc = (
"Reaction roles on this message will have this cost if they do not have an individual price set."
)
accepts = "An integer number of coins. Use `0` or `None` to make roles free by default."
_default = 0
@classmethod
def _format_data(cls, id, data, **kwargs):
if not data:
return "Free"
else:
return "`{}` coins".format(data)
@property
def success_response(self):
if self.value:
return "Reaction roles on this message will cost `{}` coins by default.".format(self.value)
else:
return "Reaction roles on this message will be free by default."
@RoleMessageSettings.attach_setting
class log(setting_types.Boolean, RoleMessageSetting):
attr_name = 'log'
_data_column = 'event_log'
display_name = "log"
desc = "Whether to log reaction role usage in the event log."
long_desc = (
"When enabled, roles added or removed with reactions will be logged in the configured event log."
)
_default = True
@property
def success_response(self):
if self.value:
return "Role updates will now be logged."
else:
return "Role updates will not be logged."
class ReactionSettings(ObjectSettings):
settings = DotDict()
class ReactionSetting(ColumnData, Setting):
_table_interface = reaction_role_reactions
_id_column = 'reactionid'
_create_row = False
write_ward = guild_admin
@ReactionSettings.attach_setting
class price(setting_types.Integer, ReactionSetting):
attr_name = 'price'
_data_column = 'price'
display_name = "price"
desc = "Price of this reaction role (may be negative)."
long_desc = (
"The number of coins that will be deducted from the user when this reaction is used.\n"
"The number may be negative, in order to give a reward when the member choses the reaction."
)
accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default."
_max = 2 ** 20
@property
def default(self):
"""
The default price is given by the ReactionMessage price setting.
"""
return default_price.get(self._table_interface.fetch(self.id).messageid).value
@classmethod
def _format_data(cls, id, data, **kwargs):
if not data:
return "Free"
else:
return "`{}` coins".format(data)
@property
def success_response(self):
if self.value is not None:
return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value)
else:
return "{reaction.emoji} {reaction.role.mention} is now free."
@ReactionSettings.attach_setting
class duration(setting_types.Duration, ReactionSetting):
attr_name = 'duration'
_data_column = 'timeout'
display_name = "duration"
desc = "How long this reaction role will last."
long_desc = (
"If set, the reaction role will be removed after the configured duration. "
"Note that this does not affect existing members with the role, or existing expiries."
)
_default_multiplier = 3600
_show_days = True
_min = 600
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "Permanent"
else:
return super()._format_data(id, data, **kwargs)
@property
def success_response(self):
if self.value is not None:
return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format(
self.formatted
)
else:
return "{reaction.emoji} {reaction.role.mention} will not expire."

View File

@@ -0,0 +1,590 @@
import asyncio
from codecs import ignore_errors
import logging
import traceback
import datetime
from collections import defaultdict
from typing import List, Mapping, Optional
from cachetools import LFUCache
import discord
from discord import PartialEmoji
from meta import client
from core import Lion
from data import Row
from data.conditions import THIS_SHARD
from utils.lib import utc_now
from settings import GuildSettings
from ..module import module
from .data import reaction_role_messages, reaction_role_reactions
from .settings import RoleMessageSettings, ReactionSettings
from .expiry import schedule_expiry, cancel_expiry
class ReactionRoleReaction:
"""
Light data class representing a reaction role reaction.
"""
__slots__ = ('reactionid', '_emoji', '_message', '_role')
def __init__(self, reactionid, message=None, **kwargs):
self.reactionid = reactionid
self._message: ReactionRoleMessage = None
self._role = None
self._emoji = None
@classmethod
def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction':
"""
Create a new ReactionRoleReaction with the provided attributes.
`emoji` sould be provided as a PartialEmoji.
`kwargs` are passed transparently to the `insert` method.
"""
row = reaction_role_reactions.create_row(
messageid=messageid,
roleid=roleid,
emoji_name=emoji.name,
emoji_id=emoji.id,
emoji_animated=emoji.animated,
**kwargs
)
return cls(row.reactionid, message=message)
@property
def emoji(self) -> PartialEmoji:
if self._emoji is None:
data = self.data
self._emoji = PartialEmoji(
name=data.emoji_name,
animated=data.emoji_animated,
id=data.emoji_id,
)
return self._emoji
@property
def data(self) -> Row:
return reaction_role_reactions.fetch(self.reactionid)
@property
def settings(self) -> ReactionSettings:
return ReactionSettings(self.reactionid)
@property
def reaction_message(self):
if self._message is None:
self._message = ReactionRoleMessage.fetch(self.data.messageid)
return self._message
@property
def role(self):
if self._role is None:
guild = self.reaction_message.guild
if guild:
self._role = guild.get_role(self.data.roleid)
return self._role
class ReactionRoleMessage:
"""
Light data class representing a reaction role message.
Primarily acts as an interface to the corresponding Settings.
"""
__slots__ = ('messageid', '_message')
# Full live messageid cache for this client. Should always be up to date.
_messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message
# Reaction cache for the live messages. Least frequently used, will be fetched on demand.
_reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions
# User-keyed locks so we only handle one reaction per user at a time
_locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock
def __init__(self, messageid):
self.messageid = messageid
self._message = None
@classmethod
def fetch(cls, messageid) -> 'ReactionRoleMessage':
"""
Fetch the ReactionRoleMessage for the provided messageid.
Returns None if the messageid is not registered.
"""
# Since the cache is assumed to be always up to date, just pass to fetch-from-cache.
return cls._messages.get(messageid, None)
@classmethod
def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage':
"""
Create a ReactionRoleMessage with the given `messageid`.
Other `kwargs` are passed transparently to `insert`.
"""
# Insert the data
reaction_role_messages.create_row(
messageid=messageid,
guildid=guildid,
channelid=channelid,
**kwargs
)
# Create the ReactionRoleMessage
rmsg = cls(messageid)
# Add to the global cache
cls._messages[messageid] = rmsg
# Return the constructed ReactionRoleMessage
return rmsg
def delete(self):
"""
Delete this ReactionRoleMessage.
"""
# Remove message from cache
self._messages.pop(self.messageid, None)
# Remove reactions from cache
reactionids = [reaction.reactionid for reaction in self.reactions]
[self._reactions.pop(reactionid, None) for reactionid in reactionids]
# Remove message from data
reaction_role_messages.delete_where(messageid=self.messageid)
@property
def data(self) -> Row:
"""
Data row associated with this Message.
Passes directly to the RowTable cache.
Should not generally be used directly, use the settings interface instead.
"""
return reaction_role_messages.fetch(self.messageid)
@property
def settings(self):
"""
RoleMessageSettings associated to this Message.
"""
return RoleMessageSettings(self.messageid)
def refresh(self):
"""
Refresh the reaction cache for this message.
Returns the generated `ReactionRoleReaction`s for convenience.
"""
# Fetch reactions and pre-populate reaction cache
rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC")
reactions = [ReactionRoleReaction(row.reactionid) for row in rows]
self._reactions[self.messageid] = reactions
return reactions
@property
def reactions(self) -> List[ReactionRoleReaction]:
"""
Returns the list of active reactions for this message, as `ReactionRoleReaction`s.
Lazily fetches the reactions from data if they have not been loaded.
"""
reactions = self._reactions.get(self.messageid, None)
if reactions is None:
reactions = self.refresh()
return reactions
@property
def enabled(self) -> bool:
"""
Whether this Message is enabled.
Passes directly to data for efficiency.
"""
return self.data.enabled
@enabled.setter
def enabled(self, value: bool):
self.data.enabled = value
# Discord properties
@property
def guild(self) -> discord.Guild:
return client.get_guild(self.data.guildid)
@property
def channel(self) -> discord.TextChannel:
return client.get_channel(self.data.channelid)
async def fetch_message(self) -> discord.Message:
if self._message:
return self._message
channel = self.channel
if channel:
try:
self._message = await channel.fetch_message(self.messageid)
return self._message
except discord.NotFound:
# The message no longer exists
# TODO: Cache and data cleanup? Or allow moving after death?
pass
@property
def message(self) -> Optional[discord.Message]:
return self._message
@property
def message_link(self) -> str:
"""
Jump link tho the reaction message.
"""
return 'https://discord.com/channels/{}/{}/{}'.format(
self.data.guildid,
self.data.channelid,
self.messageid
)
# Event handlers
async def process_raw_reaction_add(self, payload):
"""
Process a general reaction add payload.
"""
event_log = GuildSettings(self.guild.id).event_log
async with self._locks[payload.user_id]:
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
if reaction:
# User pressed a live reaction. Process!
member = payload.member
lion = Lion.fetch(member.guild.id, member.id)
role = reaction.role
if reaction.role and (role not in member.roles):
# Required role check, make sure the user has the required role, if set.
required_role = self.settings.required_role.value
if required_role and required_role not in member.roles:
# Silently remove their reaction
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Maximum check, check whether the user already has too many roles from this message.
maximum = self.settings.maximum.value
if maximum is not None:
# Fetch the number of applicable roles the user has
roleids = set(reaction.data.roleid for reaction in self.reactions)
member_roleids = set(role.id for role in member.roles)
if len(roleids.intersection(member_roleids)) >= maximum:
# Notify the user
embed = discord.Embed(
title="Maximum group roles reached!",
description=(
"Couldn't give you **{}**, "
"because you already have `{}` roles from this group!".format(
role.name,
maximum
)
)
)
# Silently try to notify the user
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Silently remove the reaction
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Economy hook, check whether the user can pay for the role.
price = reaction.settings.price.value
if price and price > lion.coins:
# They can't pay!
# Build the can't pay embed
embed = discord.Embed(
title="Insufficient funds!",
description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format(
role.name,
price,
lion.coins
),
colour=discord.Colour.red()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click here]({})".format(self.message_link)
)
# Try to send them the embed, ignore errors
try:
await member.send(
embed=embed
)
except discord.HTTPException:
pass
# Remove their reaction, ignore errors
try:
message = await self.fetch_message()
await message.remove_reaction(
payload.emoji,
member
)
except discord.HTTPException:
pass
return
# Add the role
try:
await member.add_roles(
role,
atomic=True,
reason="Adding reaction role."
)
except discord.Forbidden:
event_log.log(
"Insufficient permissions to give {} the [reaction role]({}) {}".format(
member.mention,
self.message_link,
role.mention,
),
title="Failed to add reaction role",
colour=discord.Colour.red()
)
except discord.HTTPException:
event_log.log(
"Something went wrong while adding the [reaction role]({}) "
"{} to {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Failed to add reaction role",
colour=discord.Colour.red()
)
client.log(
"Unexpected HTTPException encountered while adding '{}' (rid:{}) to "
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
role.name,
role.id,
member,
member.id,
member.guild.name,
member.guild.id,
traceback.format_exc()
),
context="REACTION_ROLE_ADD",
level=logging.WARNING
)
else:
# Charge the user and notify them, if the price is set
if price:
lion.addCoins(-price)
# Notify the user of their purchase
embed = discord.Embed(
title="Purchase successful!",
description="You have purchased **{}** for `{}` coins!".format(
role.name,
price
),
colour=discord.Colour.green()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click Here]({})".format(self.message_link)
)
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Schedule the expiry, if required
duration = reaction.settings.duration.value
if duration:
expiry = utc_now() + datetime.timedelta(seconds=duration)
schedule_expiry(self.guild.id, member.id, role.id, expiry, reaction.reactionid)
else:
expiry = None
# Log the role modification if required
if self.settings.log.value:
event_log.log(
"Added [reaction role]({}) {} "
"to {}{}.{}".format(
self.message_link,
role.mention,
member.mention,
" for `{}` coins".format(price) if price else '',
"\nThis role will expire at <t:{:.0f}>.".format(
expiry.timestamp()
) if expiry else ''
),
title="Reaction Role Added"
)
async def process_raw_reaction_remove(self, payload):
"""
Process a general reaction remove payload.
"""
if self.settings.removable.value:
event_log = GuildSettings(self.guild.id).event_log
async with self._locks[payload.user_id]:
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
if reaction:
# User removed a live reaction. Process!
member = self.guild.get_member(payload.user_id)
role = reaction.role
if member and not member.bot and role and (role in member.roles):
# Check whether they have the required role, if set
required_role = self.settings.required_role.value
if required_role and required_role not in member.roles:
# Ignore the reaction removal
return
try:
await member.remove_roles(
role,
atomic=True,
reason="Removing reaction role."
)
except discord.Forbidden:
event_log.log(
"Insufficient permissions to remove "
"the [reaction role]({}) {} from {}".format(
self.message_link,
role.mention,
member.mention,
),
title="Failed to remove reaction role",
colour=discord.Colour.red()
)
except discord.HTTPException:
event_log.log(
"Something went wrong while removing the [reaction role]({}) "
"{} from {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Failed to remove reaction role",
colour=discord.Colour.red()
)
client.log(
"Unexpected HTTPException encountered while removing '{}' (rid:{}) from "
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
role.name,
role.id,
member,
member.id,
member.guild.name,
member.guild.id,
traceback.format_exc()
),
context="REACTION_ROLE_RM",
level=logging.WARNING
)
else:
# Economy hook, handle refund if required
price = reaction.settings.price.value
refund = self.settings.refunds.value
if price and refund:
# Give the user the refund
lion = Lion.fetch(self.guild.id, member.id)
lion.addCoins(price)
# Notify the user
embed = discord.Embed(
title="Role sold",
description=(
"You sold the role **{}** for `{}` coins.".format(
role.name,
price
)
),
colour=discord.Colour.green()
).set_footer(
icon_url=self.guild.icon_url,
text=self.guild.name
).add_field(
name="Jump Back",
value="[Click Here]({})".format(self.message_link)
)
try:
await member.send(embed=embed)
except discord.HTTPException:
pass
# Log role removal if required
if self.settings.log.value:
event_log.log(
"Removed [reaction role]({}) {} "
"from {}.".format(
self.message_link,
role.mention,
member.mention
),
title="Reaction Role Removed"
)
# Cancel any existing expiry
cancel_expiry(self.guild.id, member.id, role.id)
# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them
# TODO: Handle RawMessageDelete event
# TODO: Handle permission errors when fetching message in config
@client.add_after_event('raw_reaction_add')
async def reaction_role_add(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled:
try:
await reaction_message.process_raw_reaction_add(payload)
except Exception:
# Unknown exception, catch and log it.
client.log(
"Unhandled exception while handling reaction message payload: {}\n{}".format(
payload,
traceback.format_exc()
),
context="REACTION_ROLE_ADD",
level=logging.ERROR
)
@client.add_after_event('raw_reaction_remove')
async def reaction_role_remove(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
if payload.guild_id and reaction_message and reaction_message.enabled:
try:
await reaction_message.process_raw_reaction_remove(payload)
except Exception:
# Unknown exception, catch and log it.
client.log(
"Unhandled exception while handling reaction message payload: {}\n{}".format(
payload,
traceback.format_exc()
),
context="REACTION_ROLE_RM",
level=logging.ERROR
)
@module.init_task
def load_reaction_roles(client):
"""
Load the ReactionRoleMessages.
"""
rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD)
ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows}

View File

@@ -0,0 +1,65 @@
from io import StringIO
import discord
from wards import guild_admin
from data import tables
from core import Lion
from .module import module
@module.cmd("studyreset",
desc="Perform a reset of the server's study statistics.",
group="Guild Admin")
@guild_admin()
async def cmd_statreset(ctx):
"""
Usage``:
{prefix}studyreset
Description:
Perform a complete reset of the server's study statistics.
That is, deletes the tracked time of all members and removes their study badges.
This may be used to set "seasons" of study.
Before the reset, I will send a csv file with the current member statistics.
**This is not reversible.**
"""
if not await ctx.ask("Are you sure you want to reset the study time and badges for all members? "
"**THIS IS NOT REVERSIBLE!**"):
return
# Build the data csv
rows = tables.lions.select_where(
select_columns=('userid', 'tracked_time', 'coins', 'workout_count', 'b.roleid AS badge_roleid'),
_extra=(
"LEFT JOIN study_badges b ON last_study_badgeid = b.badgeid "
"WHERE members.guildid={}"
).format(ctx.guild.id)
)
header = "userid, tracked_time, coins, workouts, rank_roleid\n"
csv_rows = [
','.join(str(data) for data in row)
for row in rows
]
with StringIO() as stats_file:
stats_file.write(header)
stats_file.write('\n'.join(csv_rows))
stats_file.seek(0)
out_file = discord.File(stats_file, filename="guild_{}_member_statistics.csv".format(ctx.guild.id))
await ctx.reply(file=out_file)
# Reset the statistics
tables.lions.update_where(
{'tracked_time': 0},
guildid=ctx.guild.id
)
Lion.sync()
await ctx.embed_reply(
"The member study times have been reset!\n"
"(It may take a while for the studybadges to update.)"
)