feature (guild admin): Reaction roles base.

This commit is contained in:
2021-10-04 10:09:07 +03:00
parent e647c67858
commit 006f2cfd6d
9 changed files with 1283 additions and 1 deletions

View File

@@ -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

View File

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

View 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
)
)
)

View 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'
)

View 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."

View 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}

View File

@@ -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)

View File

@@ -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

View File

@@ -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: