(rroles): Expiry system and small bugfixes.
Completed the `duration` reaction role setting implementation. Improved the `Duration` setting type format. Moved reaction roles to their own module. Various small bugfixes.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import settings
|
||||
from . import tracker
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from collections import defaultdict
|
||||
from discord import PartialEmoji
|
||||
|
||||
from cmdClient.lib import ResponseTimedOut, UserCancelled
|
||||
@@ -8,8 +7,7 @@ from wards import guild_admin
|
||||
from settings import UserInputError
|
||||
from utils.lib import tick, cross
|
||||
|
||||
from ..module import module
|
||||
|
||||
from .module import module
|
||||
from .tracker import ReactionRoleMessage
|
||||
from .data import reaction_role_reactions, reaction_role_messages
|
||||
from . import settings
|
||||
@@ -176,20 +174,20 @@ _message_setting_flags = {
|
||||
}
|
||||
_reaction_setting_flags = {
|
||||
'price': settings.price,
|
||||
'timeout': settings.timeout
|
||||
'duration': settings.duration
|
||||
}
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"reactionroles",
|
||||
group="Guild Admin",
|
||||
desc="Create or configure reaction role messages.",
|
||||
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=', 'timeout=='
|
||||
'price=', 'duration=='
|
||||
)
|
||||
)
|
||||
@guild_admin()
|
||||
@@ -238,12 +236,13 @@ async def cmd_reactionroles(ctx, flags):
|
||||
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)
|
||||
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
|
||||
@@ -909,7 +908,7 @@ async def cmd_reactionroles(ctx, flags):
|
||||
"{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 more usage information and examples with `{prefix}help rroles`."
|
||||
"See examples and more usage information with `{prefix}help rroles`."
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
settings_table=target.settings.tabulated()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from data import RowTable
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
reaction_role_messages = RowTable(
|
||||
@@ -17,3 +17,6 @@ reaction_role_reactions = RowTable(
|
||||
('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'),
|
||||
'reactionid'
|
||||
)
|
||||
|
||||
|
||||
reaction_role_expiring = Table('reaction_role_expiring')
|
||||
|
||||
172
bot/modules/guild_admin/reaction_roles/expiry.py
Normal file
172
bot/modules/guild_admin/reaction_roles/expiry.py
Normal 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 = utc_now().timestamp() - _expiring[key] 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"
|
||||
)
|
||||
3
bot/modules/guild_admin/reaction_roles/module.py
Normal file
3
bot/modules/guild_admin/reaction_roles/module.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Reaction_Roles")
|
||||
@@ -223,11 +223,11 @@ class price(setting_types.Integer, ReactionSetting):
|
||||
|
||||
|
||||
@ReactionSettings.attach_setting
|
||||
class timeout(setting_types.Duration, ReactionSetting):
|
||||
attr_name = 'timeout'
|
||||
class duration(setting_types.Duration, ReactionSetting):
|
||||
attr_name = 'duration'
|
||||
_data_column = 'timeout'
|
||||
|
||||
display_name = "timeout"
|
||||
display_name = "duration"
|
||||
desc = "How long this reaction role will last."
|
||||
|
||||
long_desc = (
|
||||
@@ -235,20 +235,22 @@ class timeout(setting_types.Duration, ReactionSetting):
|
||||
"Note that this does not affect existing members with the role, or existing expiries."
|
||||
)
|
||||
|
||||
_default_multiplier = 1
|
||||
_default_multiplier = 3600
|
||||
_show_days = True
|
||||
_min = 600
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Never"
|
||||
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 timeout `{}` after selection.".format(
|
||||
return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format(
|
||||
self.formatted
|
||||
)
|
||||
else:
|
||||
return "{reaction.emoji} {reaction.role.mention} will never timeout after selection."
|
||||
return "{reaction.emoji} {reaction.role.mention} will not expire."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from typing import List, Mapping, Optional
|
||||
from cachetools import LFUCache
|
||||
@@ -11,11 +12,13 @@ from discord import PartialEmoji
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from data import Row
|
||||
from utils.lib import utc_now
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import reaction_role_messages, reaction_role_reactions # , reaction_role_expiry
|
||||
from .data import reaction_role_messages, reaction_role_reactions
|
||||
from .settings import RoleMessageSettings, ReactionSettings
|
||||
from .expiry import schedule_expiry, cancel_expiry
|
||||
|
||||
|
||||
class ReactionRoleReaction:
|
||||
@@ -404,23 +407,30 @@ class ReactionRoleMessage:
|
||||
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(
|
||||
"to {}{}.{}".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention,
|
||||
" for `{}` coins.".format(price) if price else ''
|
||||
" for `{}` coins.".format(price) if price else '',
|
||||
"\nThis role will expire at <t:{:.0f}>.".format(
|
||||
expiry.timestamp()
|
||||
) if expiry 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.
|
||||
@@ -519,18 +529,17 @@ class ReactionRoleMessage:
|
||||
"Removed [reaction role]({}) {} "
|
||||
"from {}.".format(
|
||||
self.message_link,
|
||||
reaction.role.mention,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Reaction Role Removed"
|
||||
)
|
||||
|
||||
# TODO: Cancel timeout
|
||||
...
|
||||
# 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
|
||||
# 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user