feature (guild admin): Reaction roles base.
This commit is contained in:
@@ -2,3 +2,4 @@ from .module import module
|
|||||||
|
|
||||||
from . import guild_config
|
from . import guild_config
|
||||||
from . import statreset
|
from . import statreset
|
||||||
|
from . import reaction_roles
|
||||||
|
|||||||
4
bot/modules/guild_admin/reaction_roles/__init__.py
Normal file
4
bot/modules/guild_admin/reaction_roles/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import data
|
||||||
|
from . import settings
|
||||||
|
from . import tracker
|
||||||
|
from . import command
|
||||||
324
bot/modules/guild_admin/reaction_roles/command.py
Normal file
324
bot/modules/guild_admin/reaction_roles/command.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from cmdClient.lib import ResponseTimedOut
|
||||||
|
from wards import guild_admin
|
||||||
|
from settings import UserInputError
|
||||||
|
|
||||||
|
from ..module import module
|
||||||
|
|
||||||
|
from .tracker import ReactionRoleMessage
|
||||||
|
from .data import reaction_role_reactions
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine"
|
||||||
|
|
||||||
|
|
||||||
|
@module.cmd(
|
||||||
|
"reactionroles",
|
||||||
|
group="Guild Admin",
|
||||||
|
desc="Create or configure reaction role messages.",
|
||||||
|
aliases=('rroles',),
|
||||||
|
flags=(
|
||||||
|
'delete', 'remove',
|
||||||
|
'enable', 'disable',
|
||||||
|
'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=',
|
||||||
|
'price=', 'timeout=='
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@guild_admin()
|
||||||
|
async def cmd_reactionroles(ctx, flags):
|
||||||
|
"""
|
||||||
|
Usage``:
|
||||||
|
{prefix}rroles messageid
|
||||||
|
{prefix}rroles messageid emoji1 role1, emoji2 role2, emoji3 role3, ...
|
||||||
|
{prefix}rroles messageid --message_setting value
|
||||||
|
{prefix}rroles messageid emoji --emoji_setting value
|
||||||
|
{prefix}rroles messageid --remove emoji1, emoji2, ...
|
||||||
|
{prefix}rroles messageid --enable
|
||||||
|
{prefix}rroles messageid --disable
|
||||||
|
{prefix}rroles messageid --delete
|
||||||
|
Description:
|
||||||
|
Reaction roles are message reactions that give members specific roles when used.
|
||||||
|
This commands allows you to create and configure messages with reaction roles.
|
||||||
|
Getting started:
|
||||||
|
To get started, choose the message you want to add reaction roles to, and copy the link to that message. \
|
||||||
|
Then run the command `{prefix}rroles link` (replacing `link` with the link you copied), and \
|
||||||
|
follow the prompts.
|
||||||
|
Configuration:
|
||||||
|
After creation, you can view the message configuration and reaction roles with `{prefix}rroles link` \
|
||||||
|
(you can also use the message id instead of the link, or reply to the message).
|
||||||
|
|
||||||
|
You can set one of the configuration options with `{prefix}rroles link --setting value`.\
|
||||||
|
For example to make it impossible to remove the reaction roles,\
|
||||||
|
run `{prefix}rroles link --removable off`.
|
||||||
|
|
||||||
|
There are also some configurable per-reaction settings, such as the price of a role.\
|
||||||
|
To see these, use `{prefix}rroles link emoji` (replacing `emoji` with the reaction emoji) \
|
||||||
|
and set them with e.g. `{prefix}rroles link emoji --price 200`.
|
||||||
|
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.
|
||||||
|
timeout: The amount of time the role lasts. (TBD)
|
||||||
|
Examples:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
if not ctx.args and not ctx.msg.reference:
|
||||||
|
# No target message provided, list the current reaction messages
|
||||||
|
# Or give a brief guide if there are no current reaction messages
|
||||||
|
...
|
||||||
|
|
||||||
|
target_id = None
|
||||||
|
target_chid = None
|
||||||
|
remaining = ""
|
||||||
|
|
||||||
|
if ctx.msg.reference:
|
||||||
|
target_id = ctx.msg.reference.message_id
|
||||||
|
target_chid = ctx.msg.reference.channel_id
|
||||||
|
remaining = ctx.args
|
||||||
|
elif ctx.args:
|
||||||
|
# Try to parse the target message
|
||||||
|
# Expect a link or a messageid as the first argument
|
||||||
|
splits = ctx.args.split(maxsplit=1)
|
||||||
|
maybe_target = splits[0]
|
||||||
|
if len(splits) > 1:
|
||||||
|
remaining = splits[1]
|
||||||
|
if maybe_target.isdigit():
|
||||||
|
# Assume it is a message id
|
||||||
|
target_id = int(maybe_target)
|
||||||
|
elif maybe_target.contains('/'):
|
||||||
|
# Assume it is a link
|
||||||
|
# Try and parse it
|
||||||
|
link_splits = maybe_target.rsplit('/', maxsplit=2)
|
||||||
|
if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit():
|
||||||
|
# Definitely a link
|
||||||
|
target_id = int(link_splits[-1])
|
||||||
|
target_chid = int(link_splits[-2])
|
||||||
|
|
||||||
|
if not target_id:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Please provide the message link or message id as the first argument."
|
||||||
|
)
|
||||||
|
|
||||||
|
# We have a target, fetch the ReactionMessage if it exists
|
||||||
|
target = ReactionRoleMessage.fetch(target_id)
|
||||||
|
if target:
|
||||||
|
# View or edit target
|
||||||
|
|
||||||
|
# Exclusive flags, delete and remove ignore all other flags
|
||||||
|
if flags['delete']:
|
||||||
|
# Handle deletion of the ReactionRoleMessage
|
||||||
|
...
|
||||||
|
if flags['remove']:
|
||||||
|
# Handle emoji removal
|
||||||
|
...
|
||||||
|
|
||||||
|
# Check whether we are editing a particular reaction
|
||||||
|
# TODO: We might be updating roles or adding reactions as well
|
||||||
|
if remaining:
|
||||||
|
emojistr = remaining
|
||||||
|
# TODO....
|
||||||
|
|
||||||
|
# Lines for edit output
|
||||||
|
edit_lines = [] # (success_state, string)
|
||||||
|
# Columns to update
|
||||||
|
update = {}
|
||||||
|
|
||||||
|
# Message edit flags
|
||||||
|
# Gets and modifies the settings, but doesn't write
|
||||||
|
if flags['disable']:
|
||||||
|
update['enabled'] = False
|
||||||
|
edit_lines.append((True, "Reaction role message disabled."))
|
||||||
|
elif flags['enable']:
|
||||||
|
update['enabled'] = True
|
||||||
|
edit_lines.append((True, "Reaction role message enabled."))
|
||||||
|
|
||||||
|
if flags['required_role']:
|
||||||
|
try:
|
||||||
|
setting = await settings.required_role.parse(target.messageid, ctx, flags['required_role'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
if flags['removable']:
|
||||||
|
try:
|
||||||
|
setting = await settings.removable.parse(target.messageid, ctx, flags['removable'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
if flags['maximum']:
|
||||||
|
try:
|
||||||
|
setting = await settings.maximum.parse(target.messageid, ctx, flags['maximum'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
if flags['refunds']:
|
||||||
|
try:
|
||||||
|
setting = await settings.refunds.parse(target.messageid, ctx, flags['refunds'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
if flags['log']:
|
||||||
|
try:
|
||||||
|
setting = await settings.log.parse(target.messageid, ctx, flags['log'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
if flags['default_price']:
|
||||||
|
try:
|
||||||
|
setting = await settings.default_price.parse(target.messageid, ctx, flags['default_price'])
|
||||||
|
except UserInputError as e:
|
||||||
|
edit_lines.append((False, e.msg))
|
||||||
|
else:
|
||||||
|
edit_lines.append((True, setting.success_response))
|
||||||
|
update[setting._data_column] = setting.data
|
||||||
|
|
||||||
|
# Update the data all at once
|
||||||
|
target.data.update(**update)
|
||||||
|
|
||||||
|
# Then format and respond with the edit message
|
||||||
|
|
||||||
|
# TODO: Emoji edit flags
|
||||||
|
...
|
||||||
|
else:
|
||||||
|
# Start creation process
|
||||||
|
# First find the target message
|
||||||
|
message = None
|
||||||
|
if target_chid:
|
||||||
|
channel = ctx.guild.get_channel(target_chid)
|
||||||
|
if channel:
|
||||||
|
message = await channel.fetch_message(target_id)
|
||||||
|
else:
|
||||||
|
# We only have a messageid, need to search for it through all the guild channels
|
||||||
|
message = await ctx.find_message(target_id)
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Could not find the specified message!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Confirm that they want to create a new reaction role message.
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
description="Do you want to set up new reaction roles for [this message]({}) (`y(es)`/`n(o)`)?".format(
|
||||||
|
message.jump_url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out_msg = await ctx.reply(embed=embed)
|
||||||
|
if not await ctx.ask(msg=None, use_msg=out_msg):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set up the message as a new ReactionRole message.
|
||||||
|
# First obtain the initial emojis
|
||||||
|
if not remaining:
|
||||||
|
# Prompt for the initial emojis
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="What reaction roles would you like to add?",
|
||||||
|
description=(
|
||||||
|
"Please now enter the reaction roles you would like to associate, "
|
||||||
|
"in the form `emoji role`, where `role` is given by id or partial name. 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:
|
||||||
|
raise ResponseTimedOut("Prompt timed out, no reaction roles were added.")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await out_msg.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rolestrs = reply.content
|
||||||
|
|
||||||
|
try:
|
||||||
|
await reply.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
rolestrs = remaining
|
||||||
|
|
||||||
|
# Attempt to parse the emojis
|
||||||
|
# First split based on newline and comma
|
||||||
|
splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(','))
|
||||||
|
splits = [split for split in splits if split]
|
||||||
|
|
||||||
|
# Do a quick check to make sure everything is in the correct format
|
||||||
|
unsplit = next((split for split in splits if ' ' not in split), None)
|
||||||
|
if unsplit:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Couldn't parse the reaction role `{}` into the form `emoji role`.".format(unsplit)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now go through and extract the emojis and roles
|
||||||
|
# TODO: Handle duplicate emojis?
|
||||||
|
# TODO: Error handling on the roles, make sure we can actually add them
|
||||||
|
reactions = {}
|
||||||
|
for split in splits:
|
||||||
|
emojistr, rolestr = split.split(maxsplit=1)
|
||||||
|
# Parse emoji
|
||||||
|
# TODO: Custom emoji handler, probably store in a PartialEmoji
|
||||||
|
if ':' in emojistr:
|
||||||
|
return ctx.error_reply(
|
||||||
|
"Sorry, at this time we only support built-in emojis! Custom emoji support coming soon."
|
||||||
|
)
|
||||||
|
emoji = emojistr
|
||||||
|
|
||||||
|
# Parse role
|
||||||
|
# TODO: More graceful error handling
|
||||||
|
role = await ctx.find_role(rolestr, interactive=True, allow_notfound=False)
|
||||||
|
|
||||||
|
reactions[emoji] = role
|
||||||
|
|
||||||
|
# TODO: Parse any provided settings, and pass them to the data constructor
|
||||||
|
|
||||||
|
# Create the ReactionRoleMessage
|
||||||
|
rmsg = ReactionRoleMessage.create(
|
||||||
|
message.id,
|
||||||
|
message.guild.id,
|
||||||
|
message.channel.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert the reaction data directly
|
||||||
|
# TODO: Again consider emoji setting data here, for common settings?
|
||||||
|
# TODO: Will need to be changed for custom emojis
|
||||||
|
reaction_role_reactions.insert_many(
|
||||||
|
*((message.id, role.id, emoji) for emoji, role in reactions.items()),
|
||||||
|
insert_keys=('messageid', 'roleid', 'emoji_name')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh the ReactionRoleMessage to pick up the new reactions
|
||||||
|
rmsg.refresh()
|
||||||
|
|
||||||
|
# Ack the creation
|
||||||
|
await ctx.embed_reply(
|
||||||
|
(
|
||||||
|
"Created `{}` new reaction roles.\n"
|
||||||
|
"Please add the reactions to [the message]({}) to make them available for use!".format(
|
||||||
|
len(reactions), rmsg.message_link
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
19
bot/modules/guild_admin/reaction_roles/data.py
Normal file
19
bot/modules/guild_admin/reaction_roles/data.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from data import 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'
|
||||||
|
)
|
||||||
248
bot/modules/guild_admin/reaction_roles/settings.py
Normal file
248
bot/modules/guild_admin/reaction_roles/settings.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
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."
|
||||||
|
|
||||||
|
|
||||||
|
@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."
|
||||||
|
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."
|
||||||
|
|
||||||
|
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."
|
||||||
|
|
||||||
|
@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 data is None:
|
||||||
|
return "Free"
|
||||||
|
else:
|
||||||
|
return "`{}` coins".format(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value is not None:
|
||||||
|
return "This role now costs `{}` coins.".format(self.value)
|
||||||
|
else:
|
||||||
|
return "This role is free."
|
||||||
|
|
||||||
|
|
||||||
|
@ReactionSettings.attach_setting
|
||||||
|
class timeout(setting_types.Duration, ReactionSetting):
|
||||||
|
attr_name = 'timeout'
|
||||||
|
_data_column = 'timeout'
|
||||||
|
|
||||||
|
display_name = "timeout"
|
||||||
|
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 = 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_data(cls, id, data, **kwargs):
|
||||||
|
if data is None:
|
||||||
|
return "Never"
|
||||||
|
else:
|
||||||
|
return super()._format_data(id, data, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value is not None:
|
||||||
|
return "Roles will timeout `{}` after selection.".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "Roles will never timeout after selection."
|
||||||
579
bot/modules/guild_admin/reaction_roles/tracker.py
Normal file
579
bot/modules/guild_admin/reaction_roles/tracker.py
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
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 settings import GuildSettings
|
||||||
|
|
||||||
|
from ..module import module
|
||||||
|
from .data import reaction_role_messages, reaction_role_reactions # , reaction_role_expiry
|
||||||
|
from .settings import RoleMessageSettings, ReactionSettings
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionRoleReaction(PartialEmoji):
|
||||||
|
"""
|
||||||
|
Light data class representing a reaction role reaction.
|
||||||
|
Extends PartialEmoji for comparison with discord Emojis.
|
||||||
|
"""
|
||||||
|
__slots__ = ('reactionid', '_message', '_role')
|
||||||
|
|
||||||
|
def __init__(self, reactionid, message=None, **kwargs):
|
||||||
|
self.reactionid = reactionid
|
||||||
|
self._message: ReactionRoleMessage = None
|
||||||
|
self._role = 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 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
|
||||||
|
|
||||||
|
# PartialEmoji properties
|
||||||
|
@property
|
||||||
|
def animated(self) -> bool:
|
||||||
|
return self.data.emoji_animated
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.data.emoji_name
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, value):
|
||||||
|
"""
|
||||||
|
Name setter.
|
||||||
|
The emoji name may get updated while the emoji itself remains the same.
|
||||||
|
"""
|
||||||
|
self.data.emoji_name = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> Optional[int]:
|
||||||
|
return self.data.emoji_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _state(self):
|
||||||
|
return client._connection
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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 == 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
|
||||||
|
|
||||||
|
# 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 ''
|
||||||
|
),
|
||||||
|
title="Reaction Role Added"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the timeout, if required
|
||||||
|
# TODO: timeout system
|
||||||
|
...
|
||||||
|
|
||||||
|
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 == 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,
|
||||||
|
reaction.role.mention,
|
||||||
|
member.mention
|
||||||
|
),
|
||||||
|
title="Reaction Role Removed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Cancel timeout
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them
|
||||||
|
# We could use the reaction url as the author url! At least for the non-builtin emojis.
|
||||||
|
# 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 not payload.member.bot 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 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()
|
||||||
|
ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows}
|
||||||
@@ -437,7 +437,7 @@ async def ask(ctx, msg, timeout=30, use_msg=None, del_on_timeout=False):
|
|||||||
out = "{} {}".format(msg, "`y(es)`/`n(o)`")
|
out = "{} {}".format(msg, "`y(es)`/`n(o)`")
|
||||||
|
|
||||||
offer_msg = use_msg or await ctx.reply(out)
|
offer_msg = use_msg or await ctx.reply(out)
|
||||||
if use_msg:
|
if use_msg and msg:
|
||||||
await use_msg.edit(content=msg)
|
await use_msg.edit(content=msg)
|
||||||
|
|
||||||
result_msg = await ctx.listen_for(["y", "yes", "n", "no"], timeout=timeout)
|
result_msg = await ctx.listen_for(["y", "yes", "n", "no"], timeout=timeout)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from cmdClient import Context
|
from cmdClient import Context
|
||||||
@@ -352,3 +353,71 @@ async def find_member(ctx, userstr, interactive=False, collection=None, silent=F
|
|||||||
await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr))
|
await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr))
|
||||||
|
|
||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
@Context.util
|
||||||
|
async def find_message(ctx, msgid, chlist=None, ignore=[]):
|
||||||
|
"""
|
||||||
|
Searches for the given message id in the guild channels.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
-------
|
||||||
|
msgid: int
|
||||||
|
The `id` of the message to search for.
|
||||||
|
chlist: Optional[List[discord.TextChannel]]
|
||||||
|
List of channels to search in.
|
||||||
|
If `None`, searches all the text channels that the `ctx.author` can read.
|
||||||
|
ignore: list
|
||||||
|
A list of channelids to explicitly ignore in the search.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Optional[discord.Message]:
|
||||||
|
If a message is found, returns the message.
|
||||||
|
Otherwise, returns `None`.
|
||||||
|
"""
|
||||||
|
if not ctx.guild:
|
||||||
|
raise InvalidContext("Cannot use this seeker outside of a guild!")
|
||||||
|
|
||||||
|
msgid = int(msgid)
|
||||||
|
|
||||||
|
# Build the channel list to search
|
||||||
|
if chlist is None:
|
||||||
|
chlist = [ch for ch in ctx.guild.text_channels if ch.permissions_for(ctx.author).read_messages]
|
||||||
|
|
||||||
|
# Remove any channels we are ignoring
|
||||||
|
chlist = [ch for ch in chlist if ch.id not in ignore]
|
||||||
|
|
||||||
|
tasks = set()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
done = set((task for task in tasks if task.done()))
|
||||||
|
tasks = tasks.difference(done)
|
||||||
|
|
||||||
|
results = [task.result() for task in done]
|
||||||
|
|
||||||
|
result = next((result for result in results if result is not None), None)
|
||||||
|
if result:
|
||||||
|
[task.cancel() for task in tasks]
|
||||||
|
return result
|
||||||
|
|
||||||
|
if i < len(chlist):
|
||||||
|
task = asyncio.create_task(_search_in_channel(chlist[i], msgid))
|
||||||
|
tasks.add(task)
|
||||||
|
i += 1
|
||||||
|
elif len(tasks) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_in_channel(channel: discord.TextChannel, msgid: int):
|
||||||
|
if channel.type != discord.ChannelType.text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
message = await channel.fetch_message(msgid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return message
|
||||||
|
|||||||
@@ -473,4 +473,42 @@ CREATE VIEW accountability_open_slots AS
|
|||||||
WHERE closed_at IS NULL
|
WHERE closed_at IS NULL
|
||||||
ORDER BY start_at ASC;
|
ORDER BY start_at ASC;
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
|
-- Reaction Roles {{{
|
||||||
|
CREATE TABLE reaction_role_messages(
|
||||||
|
messageid BIGINT PRIMARY KEY,
|
||||||
|
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
|
||||||
|
channelid BIGINT NOT NULL,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
required_role BIGINT,
|
||||||
|
removable BOOLEAN,
|
||||||
|
maximum INTEGER,
|
||||||
|
refunds BOOLEAN,
|
||||||
|
event_log BOOLEAN,
|
||||||
|
default_price INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX reaction_role_guilds ON reaction_role_messages (guildid);
|
||||||
|
|
||||||
|
CREATE TABLE reaction_role_reactions(
|
||||||
|
reactionid SERIAL PRIMARY KEY,
|
||||||
|
messageid BIGINT NOT NULL REFERENCES reaction_role_messages (messageid) ON DELETE CASCADE,
|
||||||
|
roleid BIGINT NOT NULL,
|
||||||
|
emoji_name TEXT,
|
||||||
|
emoji_id BIGINT,
|
||||||
|
emoji_animated BOOLEAN,
|
||||||
|
price INTEGER,
|
||||||
|
timeout INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX reaction_role_reaction_messages ON reaction_role_reactions (messageid);
|
||||||
|
|
||||||
|
CREATE TABLE reaction_role_expiry(
|
||||||
|
guildid BIGINT NOT NULL,
|
||||||
|
userid BIGINT NOT NULL,
|
||||||
|
roleid BIGINT NOT NULL,
|
||||||
|
expiry TIMESTAMPTZ NOT NULL,
|
||||||
|
reactionid INTEGER REFERENCES reaction_role_reactions (reactionid) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX reaction_role_expiry_members ON reaction_role_expiry (guildid, userid, roleid);
|
||||||
|
|
||||||
|
-- }}}
|
||||||
-- vim: set fdm=marker:
|
-- vim: set fdm=marker:
|
||||||
|
|||||||
Reference in New Issue
Block a user