rewrite: Initial rewrite skeleton.
Remove modules that will no longer be required. Move pending modules to pending-rewrite folders.
This commit is contained in:
7
bot/modules/pending-rewrite/guild_admin/__init__.py
Normal file
7
bot/modules/pending-rewrite/guild_admin/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .module import module
|
||||
|
||||
from . import guild_config
|
||||
from . import statreset
|
||||
from . import new_members
|
||||
from . import reaction_roles
|
||||
from . import economy
|
||||
@@ -0,0 +1,3 @@
|
||||
from ..module import module
|
||||
|
||||
from . import set_coins
|
||||
104
bot/modules/pending-rewrite/guild_admin/economy/set_coins.py
Normal file
104
bot/modules/pending-rewrite/guild_admin/economy/set_coins.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import discord
|
||||
import datetime
|
||||
from wards import guild_admin
|
||||
|
||||
from settings import GuildSettings
|
||||
from core import Lion
|
||||
|
||||
from ..module import module
|
||||
|
||||
POSTGRES_INT_MAX = 2147483647
|
||||
|
||||
@module.cmd(
|
||||
"set_coins",
|
||||
group="Guild Admin",
|
||||
desc="Set coins on a member."
|
||||
)
|
||||
@guild_admin()
|
||||
async def cmd_set(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}set_coins <user mention> <amount>
|
||||
Description:
|
||||
Sets the given number of coins on the mentioned user.
|
||||
If a number greater than 0 is mentioned, will add coins.
|
||||
If a number less than 0 is mentioned, will remove coins.
|
||||
Note: LionCoins on a member cannot be negative.
|
||||
Example:
|
||||
{prefix}set_coins {ctx.author.mention} 100
|
||||
{prefix}set_coins {ctx.author.mention} -100
|
||||
"""
|
||||
# Extract target and amount
|
||||
# Handle a slightly more flexible input than stated
|
||||
splits = ctx.args.split()
|
||||
digits = [isNumber(split) for split in splits[:2]]
|
||||
mentions = ctx.msg.mentions
|
||||
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
|
||||
return await _send_usage(ctx)
|
||||
|
||||
if all(digits):
|
||||
# Both are digits, hopefully one is a member id, and one is an amount.
|
||||
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
|
||||
if not target:
|
||||
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
|
||||
if not target:
|
||||
return await _send_usage(ctx)
|
||||
elif digits[0]:
|
||||
amount, target = int(splits[0]), mentions[0]
|
||||
elif digits[1]:
|
||||
target, amount = mentions[0], int(splits[1])
|
||||
|
||||
# Fetch the associated lion
|
||||
target_lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
|
||||
# Check sanity conditions
|
||||
if target == ctx.client.user:
|
||||
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
|
||||
if target.bot:
|
||||
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
|
||||
|
||||
# Finally, send the amount and the ack message
|
||||
# Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range
|
||||
target_coins_to_set = target_lion.coins + amount
|
||||
if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX:
|
||||
target_lion.addCoins(amount)
|
||||
elif target_coins_to_set < 0:
|
||||
target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0
|
||||
target_lion.addCoins(target_coins_to_set)
|
||||
target_coins_to_set = 0
|
||||
else:
|
||||
return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX))
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Funds Set",
|
||||
description="You have set LionCoins on {} to **{}**!".format(target.mention,target_coins_to_set),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
|
||||
|
||||
await ctx.reply(embed=embed, reference=ctx.msg)
|
||||
GuildSettings(ctx.guild.id).event_log.log(
|
||||
"{} set {}'s LionCoins to`{}`.".format(
|
||||
ctx.author.mention,
|
||||
target.mention,
|
||||
target_coins_to_set
|
||||
),
|
||||
title="Funds Set"
|
||||
)
|
||||
|
||||
def isNumber(var):
|
||||
try:
|
||||
return isinstance(int(var), int)
|
||||
except:
|
||||
return False
|
||||
|
||||
async def _send_usage(ctx):
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}set_coins <mention> <amount>`\n"
|
||||
"**Example:**\n"
|
||||
" {prefix}set_coins {ctx.author.mention} 100\n"
|
||||
" {prefix}set_coins {ctx.author.mention} -100".format(
|
||||
prefix=ctx.best_prefix,
|
||||
ctx=ctx
|
||||
)
|
||||
)
|
||||
163
bot/modules/pending-rewrite/guild_admin/guild_config.py
Normal file
163
bot/modules/pending-rewrite/guild_admin/guild_config.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import difflib
|
||||
import discord
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from wards import guild_admin, guild_moderator
|
||||
from settings import UserInputError, GuildSettings
|
||||
|
||||
from utils.lib import prop_tabulate
|
||||
import utils.ctx_addons # noqa
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
# Pages of configuration categories to display
|
||||
cat_pages = {
|
||||
'Administration': ('Meta', 'Guild Roles', 'New Members'),
|
||||
'Moderation': ('Moderation', 'Video Channels'),
|
||||
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
|
||||
'Study Rooms': ('Rented Rooms', 'Scheduled Sessions'),
|
||||
}
|
||||
|
||||
# Descriptions of each configuration category
|
||||
descriptions = {
|
||||
}
|
||||
|
||||
|
||||
@module.cmd("config",
|
||||
desc="View and modify the server settings.",
|
||||
flags=('add', 'remove'),
|
||||
group="Guild Configuration")
|
||||
@guild_moderator()
|
||||
async def cmd_config(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}config
|
||||
{prefix}config info
|
||||
{prefix}config <setting>
|
||||
{prefix}config <setting> <value>
|
||||
Description:
|
||||
Display the server configuration panel, and view/modify the server settings.
|
||||
|
||||
Use `{prefix}config` to see the settings with their current values, or `{prefix}config info` to \
|
||||
show brief descriptions instead.
|
||||
Use `{prefix}config <setting>` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \
|
||||
including the possible values.
|
||||
Finally, use `{prefix}config <setting> <value>` to set the setting to the given value.
|
||||
To unset a setting, or set it to the default, use `{prefix}config <setting> None`.
|
||||
|
||||
Additional usage for settings which accept a list of values:
|
||||
`{prefix}config <setting> <value1>, <value2>, ...`
|
||||
`{prefix}config <setting> --add <value1>, <value2>, ...`
|
||||
`{prefix}config <setting> --remove <value1>, <value2>, ...`
|
||||
Note that the first form *overwrites* the setting completely,\
|
||||
while the second two will only *add* and *remove* values, respectively.
|
||||
Examples``:
|
||||
{prefix}config event_log
|
||||
{prefix}config event_log {ctx.ch.name}
|
||||
{prefix}config autoroles Member, Level 0, Level 10
|
||||
{prefix}config autoroles --remove Level 10
|
||||
"""
|
||||
# Cache and map some info for faster access
|
||||
setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()}
|
||||
|
||||
if not ctx.args or ctx.args.lower() in ('info', 'help'):
|
||||
# Fill the setting cats
|
||||
cats = {}
|
||||
for setting in GuildSettings.settings.values():
|
||||
cat = cats.get(setting.category, [])
|
||||
cat.append(setting)
|
||||
cats[setting.category] = cat
|
||||
|
||||
# Format the cats
|
||||
sections = {}
|
||||
for catname, cat in cats.items():
|
||||
catprops = {
|
||||
setting.display_name: setting.get(ctx.guild.id).summary if not ctx.args else setting.desc
|
||||
for setting in cat
|
||||
}
|
||||
# TODO: Add cat description here
|
||||
sections[catname] = prop_tabulate(*zip(*catprops.items()))
|
||||
|
||||
# Put the cats on the correct pages
|
||||
pages = []
|
||||
for page_name, cat_names in cat_pages.items():
|
||||
page = {
|
||||
cat_name: sections[cat_name] for cat_name in cat_names if cat_name in sections
|
||||
}
|
||||
if page:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=page_name,
|
||||
description=(
|
||||
"View brief setting descriptions with `{prefix}config info`.\n"
|
||||
"Use e.g. `{prefix}config event_log` to see more details.\n"
|
||||
"Modify a setting with e.g. `{prefix}config event_log {ctx.ch.name}`.\n"
|
||||
"See the [Online Tutorial]({tutorial}) for a complete setup guide.".format(
|
||||
prefix=ctx.best_prefix,
|
||||
ctx=ctx,
|
||||
tutorial="https://discord.studylions.com/tutorial"
|
||||
)
|
||||
)
|
||||
)
|
||||
for name, value in page.items():
|
||||
embed.add_field(name=name, value=value, inline=False)
|
||||
|
||||
pages.append(embed)
|
||||
|
||||
if len(pages) > 1:
|
||||
[
|
||||
embed.set_footer(text="Page {} of {}".format(i+1, len(pages)))
|
||||
for i, embed in enumerate(pages)
|
||||
]
|
||||
await ctx.pager(pages)
|
||||
elif pages:
|
||||
await ctx.reply(embed=pages[0])
|
||||
else:
|
||||
await ctx.reply("No configuration options set up yet!")
|
||||
else:
|
||||
# Some args were given
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
|
||||
name = parts[0]
|
||||
setting = setting_displaynames.get(name.lower(), None)
|
||||
if setting is None:
|
||||
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
|
||||
match = "`{}`".format('` or `'.join(matches)) if matches else None
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find a setting called `{}`!\n"
|
||||
"{}"
|
||||
"Use `{}config info` to see all the server settings.".format(
|
||||
name,
|
||||
"Maybe you meant {}?\n".format(match) if match else "",
|
||||
ctx.best_prefix
|
||||
)
|
||||
)
|
||||
|
||||
if len(parts) == 1 and not ctx.msg.attachments:
|
||||
# config <setting>
|
||||
# View config embed for provided setting
|
||||
await setting.get(ctx.guild.id).widget(ctx, flags=flags)
|
||||
else:
|
||||
# config <setting> <value>
|
||||
# Ignoring the write ward currently and just enforcing admin
|
||||
# Check the write ward
|
||||
# if not await setting.write_ward.run(ctx):
|
||||
# raise SafeCancellation(setting.write_ward.msg)
|
||||
if not await guild_admin.run(ctx):
|
||||
raise SafeCancellation("You need to be a server admin to modify settings!")
|
||||
|
||||
# Attempt to set config setting
|
||||
try:
|
||||
parsed = await setting.parse(ctx.guild.id, ctx, parts[1] if len(parts) > 1 else '')
|
||||
parsed.write(add_only=flags['add'], remove_only=flags['remove'])
|
||||
except UserInputError as e:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('❌', e.msg),
|
||||
colour=discord.Colour.red()
|
||||
))
|
||||
else:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('✅', setting.get(ctx.guild.id).success_response),
|
||||
colour=discord.Colour.green()
|
||||
))
|
||||
4
bot/modules/pending-rewrite/guild_admin/module.py
Normal file
4
bot/modules/pending-rewrite/guild_admin/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Guild_Admin")
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import settings
|
||||
from . import greetings
|
||||
from . import roles
|
||||
@@ -0,0 +1,6 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
autoroles = Table('autoroles')
|
||||
bot_autoroles = Table('bot_autoroles')
|
||||
past_member_roles = Table('past_member_roles')
|
||||
@@ -0,0 +1,29 @@
|
||||
import discord
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from meta import client
|
||||
|
||||
from .settings import greeting_message, greeting_channel, returning_message
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def send_greetings(client, member):
|
||||
guild = member.guild
|
||||
|
||||
returning = bool(client.data.lions.fetch((guild.id, member.id)))
|
||||
|
||||
# Handle greeting message
|
||||
channel = greeting_channel.get(guild.id).value
|
||||
if channel is not None:
|
||||
if channel == greeting_channel.DMCHANNEL:
|
||||
channel = member
|
||||
|
||||
ctx = Context(client, guild=guild, author=member)
|
||||
if returning:
|
||||
args = returning_message.get(guild.id).args(ctx)
|
||||
else:
|
||||
args = greeting_message.get(guild.id).args(ctx)
|
||||
try:
|
||||
await channel.send(**args)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
115
bot/modules/pending-rewrite/guild_admin/new_members/roles.py
Normal file
115
bot/modules/pending-rewrite/guild_admin/new_members/roles.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from collections import defaultdict
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from settings import GuildSettings
|
||||
|
||||
from .settings import autoroles, bot_autoroles, role_persistence
|
||||
from .data import past_member_roles
|
||||
|
||||
|
||||
# Locks to avoid storing the roles while adding them
|
||||
# The locking is cautious, leaving data unchanged upon collision
|
||||
locks = defaultdict(asyncio.Lock)
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def join_role_tracker(client, member):
|
||||
"""
|
||||
Add autoroles or saved roles as needed.
|
||||
"""
|
||||
guild = member.guild
|
||||
if not guild.me.guild_permissions.manage_roles:
|
||||
# We can't manage the roles here, don't try to give/restore the member roles
|
||||
return
|
||||
|
||||
async with locks[(guild.id, member.id)]:
|
||||
if role_persistence.get(guild.id).value and client.data.lions.fetch((guild.id, member.id)):
|
||||
# Lookup stored roles
|
||||
role_rows = past_member_roles.select_where(
|
||||
guildid=guild.id,
|
||||
userid=member.id
|
||||
)
|
||||
# Identify roles from roleids
|
||||
roles = (guild.get_role(row['roleid']) for row in role_rows)
|
||||
# Remove non-existent roles
|
||||
roles = (role for role in roles if role is not None)
|
||||
# Remove roles the client can't add
|
||||
roles = [role for role in roles if role < guild.me.top_role]
|
||||
if roles:
|
||||
try:
|
||||
await member.add_roles(
|
||||
*roles,
|
||||
reason="Restoring saved roles.",
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# This shouldn't ususally happen, but there are valid cases where it can
|
||||
# E.g. the user left while we were restoring their roles
|
||||
pass
|
||||
# Event log!
|
||||
GuildSettings(guild.id).event_log.log(
|
||||
"Restored the following roles for returning member {}:\n{}".format(
|
||||
member.mention,
|
||||
', '.join(role.mention for role in roles)
|
||||
),
|
||||
title="Saved roles restored"
|
||||
)
|
||||
else:
|
||||
# Add autoroles
|
||||
roles = bot_autoroles.get(guild.id).value if member.bot else autoroles.get(guild.id).value
|
||||
# Remove roles the client can't add
|
||||
roles = [role for role in roles if role < guild.me.top_role]
|
||||
if roles:
|
||||
try:
|
||||
await member.add_roles(
|
||||
*roles,
|
||||
reason="Adding autoroles.",
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# This shouldn't ususally happen, but there are valid cases where it can
|
||||
# E.g. the user left while we were adding autoroles
|
||||
pass
|
||||
# Event log!
|
||||
GuildSettings(guild.id).event_log.log(
|
||||
"Gave {} the guild autoroles:\n{}".format(
|
||||
member.mention,
|
||||
', '.join(role.mention for role in roles)
|
||||
),
|
||||
titles="Autoroles added"
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('member_remove')
|
||||
async def left_role_tracker(client, member):
|
||||
"""
|
||||
Delete and re-store member roles when they leave the server.
|
||||
"""
|
||||
if (member.guild.id, member.id) in locks and locks[(member.guild.id, member.id)].locked():
|
||||
# Currently processing a join event
|
||||
# Which means the member left while we were adding their roles
|
||||
# Cautiously return, not modifying the saved role data
|
||||
return
|
||||
|
||||
# Delete existing member roles for this user
|
||||
# NOTE: Not concurrency-safe
|
||||
past_member_roles.delete_where(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id,
|
||||
)
|
||||
if role_persistence.get(member.guild.id).value:
|
||||
# Make sure the user has an associated lion, so we can detect when they rejoin
|
||||
Lion.fetch(member.guild.id, member.id)
|
||||
|
||||
# Then insert the current member roles
|
||||
values = [
|
||||
(member.guild.id, member.id, role.id)
|
||||
for role in member.roles
|
||||
if not role.is_bot_managed() and not role.is_integration() and not role.is_default()
|
||||
]
|
||||
if values:
|
||||
past_member_roles.insert_many(
|
||||
*values,
|
||||
insert_keys=('guildid', 'userid', 'roleid')
|
||||
)
|
||||
303
bot/modules/pending-rewrite/guild_admin/new_members/settings.py
Normal file
303
bot/modules/pending-rewrite/guild_admin/new_members/settings.py
Normal file
@@ -0,0 +1,303 @@
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
import settings
|
||||
from settings import GuildSettings, GuildSetting
|
||||
import settings.setting_types as stypes
|
||||
from wards import guild_admin
|
||||
|
||||
from .data import autoroles, bot_autoroles
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class greeting_channel(stypes.Channel, GuildSetting):
|
||||
"""
|
||||
Setting describing the destination of the greeting message.
|
||||
|
||||
Extended to support the following special values, with input and output supported.
|
||||
Data `None` corresponds to `Off`.
|
||||
Data `1` corresponds to `DM`.
|
||||
"""
|
||||
DMCHANNEL = object()
|
||||
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'greeting_channel'
|
||||
_data_column = 'greeting_channel'
|
||||
|
||||
display_name = "welcome_channel"
|
||||
desc = "Channel to send the welcome message in"
|
||||
|
||||
long_desc = (
|
||||
"Channel to post the `welcome_message` in when a new user joins the server. "
|
||||
"Accepts `DM` to indicate the welcome should be sent via direct message."
|
||||
)
|
||||
_accepts = (
|
||||
"Text Channel name/id/mention, or `DM`, or `None` to disable."
|
||||
)
|
||||
_chan_type = discord.ChannelType.text
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return None
|
||||
elif data == 1:
|
||||
return cls.DMCHANNEL
|
||||
else:
|
||||
return super()._data_to_value(id, data, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, id, value, **kwargs):
|
||||
if value is None:
|
||||
return None
|
||||
elif value == cls.DMCHANNEL:
|
||||
return 1
|
||||
else:
|
||||
return super()._data_from_value(id, value, **kwargs)
|
||||
|
||||
@classmethod
|
||||
async def _parse_userstr(cls, ctx, id, userstr, **kwargs):
|
||||
lower = userstr.lower()
|
||||
if lower in ('0', 'none', 'off'):
|
||||
return None
|
||||
elif lower == 'dm':
|
||||
return 1
|
||||
else:
|
||||
return await super()._parse_userstr(ctx, id, userstr, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Off"
|
||||
elif data == 1:
|
||||
return "DM"
|
||||
else:
|
||||
return "<#{}>".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
value = self.value
|
||||
if not value:
|
||||
return "Welcome messages are disabled."
|
||||
elif value == self.DMCHANNEL:
|
||||
return "Welcome messages will be sent via direct message."
|
||||
else:
|
||||
return "Welcome messages will be posted in {}".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class greeting_message(stypes.Message, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'greeting_message'
|
||||
_data_column = 'greeting_message'
|
||||
|
||||
display_name = 'welcome_message'
|
||||
desc = "Welcome message sent to welcome new members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `welcome_channel` when a member joins the server for the first time."
|
||||
)
|
||||
|
||||
_default = r"""
|
||||
{
|
||||
"embed": {
|
||||
"title": "Welcome!",
|
||||
"thumbnail": {"url": "{guild_icon}"},
|
||||
"description": "Hi {mention}!\nWelcome to **{guild_name}**! You are the **{member_count}**th member.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
|
||||
"color": 15695665
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_substitution_desc = {
|
||||
'{mention}': "Mention the new member.",
|
||||
'{user_name}': "Username of the new member.",
|
||||
'{user_avatar}': "Avatar of the new member.",
|
||||
'{guild_name}': "Name of this server.",
|
||||
'{guild_icon}': "Server icon url.",
|
||||
'{member_count}': "Number of members in the server.",
|
||||
'{studying_count}': "Number of current voice channel members.",
|
||||
}
|
||||
|
||||
def substitution_keys(self, ctx, **kwargs):
|
||||
return {
|
||||
'{mention}': ctx.author.mention,
|
||||
'{user_name}': ctx.author.name,
|
||||
'{user_avatar}': str(ctx.author.avatar_url),
|
||||
'{guild_name}': ctx.guild.name,
|
||||
'{guild_icon}': str(ctx.guild.icon_url),
|
||||
'{member_count}': str(len(ctx.guild.members)),
|
||||
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members]))
|
||||
}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The welcome message has been set!"
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class returning_message(stypes.Message, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'returning_message'
|
||||
_data_column = 'returning_message'
|
||||
|
||||
display_name = 'returning_message'
|
||||
desc = "Welcome message sent to returning members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `welcome_channel` when a member returns to the server."
|
||||
)
|
||||
|
||||
_default = r"""
|
||||
{
|
||||
"embed": {
|
||||
"title": "Welcome Back {user_name}!",
|
||||
"thumbnail": {"url": "{guild_icon}"},
|
||||
"description": "Welcome back to **{guild_name}**!\nYou last studied with us <t:{last_time}:R>.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
|
||||
"color": 15695665
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_substitution_desc = {
|
||||
'{mention}': "Mention the returning member.",
|
||||
'{user_name}': "Username of the member.",
|
||||
'{user_avatar}': "Avatar of the member.",
|
||||
'{guild_name}': "Name of this server.",
|
||||
'{guild_icon}': "Server icon url.",
|
||||
'{member_count}': "Number of members in the server.",
|
||||
'{studying_count}': "Number of current voice channel members.",
|
||||
'{last_time}': "Unix timestamp of the last time the member studied.",
|
||||
}
|
||||
|
||||
def substitution_keys(self, ctx, **kwargs):
|
||||
return {
|
||||
'{mention}': ctx.author.mention,
|
||||
'{user_name}': ctx.author.name,
|
||||
'{user_avatar}': str(ctx.author.avatar_url),
|
||||
'{guild_name}': ctx.guild.name,
|
||||
'{guild_icon}': str(ctx.guild.icon_url),
|
||||
'{member_count}': str(len(ctx.guild.members)),
|
||||
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])),
|
||||
'{last_time}': int(ctx.alion.data._timestamp.replace(tzinfo=datetime.timezone.utc).timestamp()),
|
||||
}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The returning message has been set!"
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class starting_funds(stypes.Integer, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'starting_funds'
|
||||
_data_column = 'starting_funds'
|
||||
|
||||
display_name = 'starting_funds'
|
||||
desc = "Coins given when a user first joins."
|
||||
|
||||
long_desc = (
|
||||
"Members will be given this number of coins the first time they join the server."
|
||||
)
|
||||
|
||||
_default = 1000
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be given `{}` coins when they first join the server.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class autoroles(stypes.RoleList, settings.ListData, settings.Setting):
|
||||
category = "New Members"
|
||||
write_ward = guild_admin
|
||||
|
||||
attr_name = 'autoroles'
|
||||
|
||||
_table_interface = autoroles
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'roleid'
|
||||
|
||||
display_name = "autoroles"
|
||||
desc = "Roles to give automatically to new members."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"These roles will be given automatically to users when they join the server. "
|
||||
"If `role_persistence` is enabled, the roles will only be given the first time a user joins the server."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "New members will be given the following roles:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "New members will not automatically be given any roles."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class bot_autoroles(stypes.RoleList, settings.ListData, settings.Setting):
|
||||
category = "New Members"
|
||||
write_ward = guild_admin
|
||||
|
||||
attr_name = 'bot_autoroles'
|
||||
|
||||
_table_interface = bot_autoroles
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'roleid'
|
||||
|
||||
display_name = "bot_autoroles"
|
||||
desc = "Roles to give automatically to new bots."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"These roles will be given automatically to bots when they join the server. "
|
||||
"If `role_persistence` is enabled, the roles will only be given the first time a bot joins the server."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "New bots will be given the following roles:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "New bots will not automatically be given any roles."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class role_persistence(stypes.Boolean, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = "role_persistence"
|
||||
|
||||
_data_column = 'persist_roles'
|
||||
|
||||
display_name = "role_persistence"
|
||||
desc = "Whether to remember member roles when they leave the server."
|
||||
_outputs = {True: "Enabled", False: "Disabled"}
|
||||
_default = True
|
||||
|
||||
long_desc = (
|
||||
"When enabled, restores member roles when they rejoin the server.\n"
|
||||
"This enables profile roles and purchased roles, such as field of study and colour roles, "
|
||||
"as well as moderation roles, "
|
||||
"such as the studyban and mute roles, to persist even when a member leaves and rejoins.\n"
|
||||
"Note: Members who leave while this is disabled will not have their roles restored."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Roles will now be restored when a member rejoins."
|
||||
else:
|
||||
return "Member roles will no longer be saved or restored."
|
||||
@@ -0,0 +1,6 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import settings
|
||||
from . import tracker
|
||||
from . import command
|
||||
@@ -0,0 +1,943 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from discord import PartialEmoji
|
||||
|
||||
from cmdClient.lib import ResponseTimedOut, UserCancelled
|
||||
from wards import guild_admin
|
||||
from settings import UserInputError
|
||||
from utils.lib import tick, cross
|
||||
|
||||
from .module import module
|
||||
from .tracker import ReactionRoleMessage
|
||||
from .data import reaction_role_reactions, reaction_role_messages
|
||||
from . import settings
|
||||
|
||||
|
||||
example_emoji = "🧮"
|
||||
example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine"
|
||||
|
||||
|
||||
def _parse_messageref(ctx):
|
||||
"""
|
||||
Parse a message reference from the context message and return it.
|
||||
Removes the parsed string from `ctx.args` if applicable.
|
||||
Supports the following reference types, in precedence order:
|
||||
- A Discord message reply reference.
|
||||
- A message link.
|
||||
- A message id.
|
||||
|
||||
Returns: (channelid, messageid)
|
||||
`messageid` will be `None` if a valid reference was not found.
|
||||
`channelid` will be `None` if the message was provided by pure id.
|
||||
"""
|
||||
target_id = None
|
||||
target_chid = None
|
||||
|
||||
if ctx.msg.reference:
|
||||
# True message reference extract message and return
|
||||
target_id = ctx.msg.reference.message_id
|
||||
target_chid = ctx.msg.reference.channel_id
|
||||
elif ctx.args:
|
||||
# Parse the first word of the message arguments
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
maybe_target = splits[0]
|
||||
|
||||
# Expect a message id or message link
|
||||
if maybe_target.isdigit():
|
||||
# Assume it is a message id
|
||||
target_id = int(maybe_target)
|
||||
elif '/' in maybe_target:
|
||||
# Assume it is a link
|
||||
# Split out the channelid and messageid, if possible
|
||||
link_splits = maybe_target.rsplit('/', maxsplit=2)
|
||||
if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit():
|
||||
target_id = int(link_splits[-1])
|
||||
target_chid = int(link_splits[-2])
|
||||
|
||||
# If we found a target id, truncate the arguments
|
||||
if target_id is not None:
|
||||
if len(splits) > 1:
|
||||
ctx.args = splits[1].strip()
|
||||
else:
|
||||
ctx.args = ""
|
||||
else:
|
||||
# Last-ditch attempt, see if the argument could be a stored reaction
|
||||
maybe_emoji = maybe_target.strip(',')
|
||||
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
|
||||
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
|
||||
emojis = {reaction.emoji: message for message in messages for reaction in message.reactions}
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id}
|
||||
result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map)
|
||||
if result and result in emojis:
|
||||
message = emojis[result]
|
||||
target_id = message.messageid
|
||||
target_chid = message.data.channelid
|
||||
|
||||
# Return the message reference
|
||||
return (target_chid, target_id)
|
||||
|
||||
|
||||
def _parse_emoji(emoji_str, name_map, id_map):
|
||||
"""
|
||||
Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids.
|
||||
"""
|
||||
emoji = None
|
||||
if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str):
|
||||
# The string is pure unicode, we assume built in emoji
|
||||
emoji = PartialEmoji(name=emoji_str)
|
||||
elif emoji_str.lower() in name_map:
|
||||
emoji = name_map[emoji_str.lower()]
|
||||
elif emoji_str.isdigit() and int(emoji_str) in id_map:
|
||||
emoji = id_map[int(emoji_str)]
|
||||
else:
|
||||
# Attempt to parse as custom emoji
|
||||
# Accept custom emoji provided in the full form
|
||||
emoji_split = emoji_str.strip('<>:').split(':')
|
||||
if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit():
|
||||
emoji_id = int(emoji_split[-1])
|
||||
emoji_name = emoji_split[-2]
|
||||
emoji_animated = emoji_split[0] == 'a'
|
||||
emoji = PartialEmoji(
|
||||
name=emoji_name,
|
||||
id=emoji_id,
|
||||
animated=emoji_animated
|
||||
)
|
||||
return emoji
|
||||
|
||||
|
||||
async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None):
|
||||
"""
|
||||
Asks the author the provided question in an embed, and provides check/cross reactions for answering.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=question
|
||||
)
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
# Wait for a tick/cross
|
||||
asyncio.create_task(out_msg.add_reaction(tick))
|
||||
asyncio.create_task(out_msg.add_reaction(cross))
|
||||
|
||||
def check(reaction, user):
|
||||
result = True
|
||||
result = result and reaction.message == out_msg
|
||||
result = result and user == ctx.author
|
||||
result = result and (reaction.emoji == tick or reaction.emoji == cross)
|
||||
return result
|
||||
|
||||
try:
|
||||
reaction, _ = await ctx.client.wait_for(
|
||||
'reaction_add',
|
||||
check=check,
|
||||
timeout=120
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description=timeout_msg or "Prompt timed out."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
raise ResponseTimedOut from None
|
||||
if reaction.emoji == cross:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description=cancel_msg or "Cancelled."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
raise UserCancelled from None
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_message_setting_flags = {
|
||||
'removable': settings.removable,
|
||||
'maximum': settings.maximum,
|
||||
'required_role': settings.required_role,
|
||||
'log': settings.log,
|
||||
'refunds': settings.refunds,
|
||||
'default_price': settings.default_price,
|
||||
}
|
||||
_reaction_setting_flags = {
|
||||
'price': settings.price,
|
||||
'duration': settings.duration
|
||||
}
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"reactionroles",
|
||||
group="Guild Configuration",
|
||||
desc="Create and configure reaction role messages.",
|
||||
aliases=('rroles',),
|
||||
flags=(
|
||||
'delete', 'remove==',
|
||||
'enable', 'disable',
|
||||
'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=',
|
||||
'price=', 'duration=='
|
||||
)
|
||||
)
|
||||
@guild_admin()
|
||||
async def cmd_reactionroles(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}rroles
|
||||
{prefix}rroles [enable|disable|delete] msglink
|
||||
{prefix}rroles msglink [emoji1 role1, emoji2 role2, ...]
|
||||
{prefix}rroles msglink --remove emoji1, emoji2, ...
|
||||
{prefix}rroles msglink --message_setting [value]
|
||||
{prefix}rroles msglink emoji --reaction_setting [value]
|
||||
Description:
|
||||
Create and configure "reaction roles", i.e. roles obtainable by \
|
||||
clicking reactions on a particular message.
|
||||
`msglink` is the link or message id of the message with reactions.
|
||||
`emoji` should be given as the emoji itself, or the name or id.
|
||||
`role` may be given by name, mention, or id.
|
||||
Getting started:
|
||||
First choose the message you want to add reaction roles to, \
|
||||
and copy the link or message id for that message. \
|
||||
Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \
|
||||
and follow the prompts.
|
||||
For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead.
|
||||
Editing reaction roles:
|
||||
Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...`
|
||||
Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...`
|
||||
Examples``:
|
||||
{prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine
|
||||
{prefix}rroles disable {ctx.msg.id}
|
||||
PAGEBREAK:
|
||||
Page 2
|
||||
Advanced configuration:
|
||||
Type `{prefix}rroles link` again to view the advanced setting window, \
|
||||
and use `{prefix}rroles link --setting value` to modify the settings. \
|
||||
See below for descriptions of each message setting.
|
||||
For example to disable event logging, run `{prefix}rroles link --log off`.
|
||||
|
||||
For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`.
|
||||
|
||||
*(!) Replace `setting` with one of the settings below!*
|
||||
Message Settings::
|
||||
maximum: Maximum number of roles obtainable from this message.
|
||||
log: Whether to log reaction role usage into the event log.
|
||||
removable: Whether the reactions roles can be remove by unreacting.
|
||||
refunds: Whether to refund the role price when removing the role.
|
||||
default_price: The default price of each role on this message.
|
||||
required_role: The role required to use these reactions roles.
|
||||
Reaction Settings::
|
||||
price: The price of this reaction role. (May be negative for a reward.)
|
||||
tduration: How long this role will last after being selected or bought.
|
||||
Configuration Examples``:
|
||||
{prefix}rroles {ctx.msg.id} --maximum 5
|
||||
{prefix}rroles {ctx.msg.id} --default_price 20
|
||||
{prefix}rroles {ctx.msg.id} --required_role None
|
||||
{prefix}rroles {ctx.msg.id} 🧮 --price 1024
|
||||
{prefix}rroles {ctx.msg.id} 🧮 --duration 7 days
|
||||
"""
|
||||
if not ctx.args:
|
||||
# No target message provided, list the current reaction messages
|
||||
# Or give a brief guide if there are no current reaction messages
|
||||
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
|
||||
if guild_message_rows:
|
||||
# List messages
|
||||
|
||||
# First get the list of reaction role messages in the guild
|
||||
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
|
||||
|
||||
# Sort them by channelid and messageid
|
||||
messages.sort(key=lambda m: (m.data.channelid, m.messageid))
|
||||
|
||||
# Build the message description strings
|
||||
message_strings = []
|
||||
for message in messages:
|
||||
header = (
|
||||
"`{}` in <#{}> ([Click to jump]({})){}".format(
|
||||
message.messageid,
|
||||
message.data.channelid,
|
||||
message.message_link,
|
||||
" (disabled)" if not message.enabled else ""
|
||||
)
|
||||
)
|
||||
role_strings = [
|
||||
"{} <@&{}>".format(reaction.emoji, reaction.data.roleid)
|
||||
for reaction in message.reactions
|
||||
]
|
||||
role_string = '\n'.join(role_strings) or "No reaction roles!"
|
||||
|
||||
message_strings.append("{}\n{}".format(header, role_string))
|
||||
|
||||
pages = []
|
||||
page = []
|
||||
page_len = 0
|
||||
page_chars = 0
|
||||
i = 0
|
||||
while i < len(message_strings):
|
||||
message_string = message_strings[i]
|
||||
chars = len(message_string)
|
||||
lines = len(message_string.splitlines())
|
||||
if (page and lines + page_len > 20) or (chars + page_chars > 2000):
|
||||
pages.append('\n\n'.join(page))
|
||||
page = []
|
||||
page_len = 0
|
||||
page_chars = 0
|
||||
else:
|
||||
page.append(message_string)
|
||||
page_len += lines
|
||||
page_chars += chars
|
||||
i += 1
|
||||
if page:
|
||||
pages.append('\n\n'.join(page))
|
||||
|
||||
page_count = len(pages)
|
||||
title = "Reaction Roles in {}".format(ctx.guild.name)
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=page,
|
||||
title=title
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
if page_count > 1:
|
||||
[embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)]
|
||||
await ctx.pager(embeds)
|
||||
else:
|
||||
# Send a setup guide
|
||||
embed = discord.Embed(
|
||||
title="No Reaction Roles set up!",
|
||||
description=(
|
||||
"To setup reaction roles, first copy the link or message id of the message you want to "
|
||||
"add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, "
|
||||
"and follow the prompts.\n"
|
||||
"See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix)
|
||||
),
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
return
|
||||
|
||||
# Extract first word, look for a subcommand
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
subcmd = splits[0].lower()
|
||||
|
||||
if subcmd in ('enable', 'disable', 'delete'):
|
||||
# Truncate arguments and extract target
|
||||
if len(splits) > 1:
|
||||
ctx.args = splits[1]
|
||||
target_chid, target_id = _parse_messageref(ctx)
|
||||
else:
|
||||
target_chid = None
|
||||
target_id = None
|
||||
ctx.args = ''
|
||||
|
||||
# Handle subcommand special cases
|
||||
if subcmd == 'enable':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to enable!\n"
|
||||
"**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm enabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to enable all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no reaction roles enabled.",
|
||||
cancel_msg="User cancelled, no reaction roles enabled."
|
||||
)
|
||||
reaction_role_messages.update_where(
|
||||
{'enabled': True},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been enabled.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles!\n"
|
||||
"Run the command again without `enable` to assign reaction roles."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
if target.enabled:
|
||||
await ctx.error_reply(
|
||||
"This message is already enabled!"
|
||||
)
|
||||
else:
|
||||
target.enabled = True
|
||||
await ctx.embed_reply(
|
||||
"The message has been enabled!"
|
||||
)
|
||||
elif subcmd == 'disable':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to disable!\n"
|
||||
"**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm disabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to disable all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no reaction roles disabled.",
|
||||
cancel_msg="User cancelled, no reaction roles disabled."
|
||||
)
|
||||
reaction_role_messages.update_where(
|
||||
{'enabled': False},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been disabled.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles! Nothing to disable."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
if not target.enabled:
|
||||
await ctx.error_reply(
|
||||
"This message is already disabled!"
|
||||
)
|
||||
else:
|
||||
target.enabled = False
|
||||
await ctx.embed_reply(
|
||||
"The message has been disabled!"
|
||||
)
|
||||
elif subcmd == 'delete':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to remove!\n"
|
||||
"**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm disabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to remove all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no messages removed.",
|
||||
cancel_msg="User cancelled, no messages removed."
|
||||
)
|
||||
reaction_role_messages.delete_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been removed.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles! Nothing to remove."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
target.delete()
|
||||
await ctx.embed_reply(
|
||||
"The message has been removed and is no longer a reaction role message."
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Just extract target
|
||||
target_chid, target_id = _parse_messageref(ctx)
|
||||
|
||||
# Handle target parsing issue
|
||||
if target_id is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as a message id or message link!\n"
|
||||
"See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Get the associated ReactionRoleMessage, if it exists
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
|
||||
# Get the target message
|
||||
if target:
|
||||
message = await target.fetch_message()
|
||||
if not message:
|
||||
# TODO: Consider offering some sort of `move` option here.
|
||||
await ctx.error_reply(
|
||||
"This reaction role message no longer exists!\n"
|
||||
"Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid)
|
||||
)
|
||||
else:
|
||||
message = None
|
||||
if target_chid:
|
||||
channel = ctx.guild.get_channel(target_chid)
|
||||
if not channel:
|
||||
await ctx.error_reply(
|
||||
"The provided channel no longer exists!"
|
||||
)
|
||||
elif not isinstance(channel, discord.TextChannel):
|
||||
await ctx.error_reply(
|
||||
"The provided channel is not a text channel!"
|
||||
)
|
||||
else:
|
||||
message = await channel.fetch_message(target_id)
|
||||
if not message:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the specified message in {}!".format(channel.mention)
|
||||
)
|
||||
else:
|
||||
out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id))
|
||||
message = await ctx.find_message(target_id)
|
||||
try:
|
||||
await out_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if not message:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message `{}`!".format(target_id)
|
||||
)
|
||||
if not message:
|
||||
return
|
||||
|
||||
# Handle the `remove` flag specially
|
||||
# In particular, all other flags are ignored
|
||||
if flags['remove']:
|
||||
if not target:
|
||||
await ctx.error_reply(
|
||||
"The specified message has no reaction roles! Nothing to remove."
|
||||
)
|
||||
else:
|
||||
# Parse emojis and remove from target
|
||||
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in target_emojis}
|
||||
|
||||
items = [item.strip() for item in flags['remove'].split(',')]
|
||||
to_remove = [] # List of reactions to remove
|
||||
for emoji_str in items:
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str)
|
||||
)
|
||||
if emoji not in target_emojis:
|
||||
return await ctx.error_reply(
|
||||
"{} is not a reaction role for this message!".format(emoji)
|
||||
)
|
||||
to_remove.append(target_emojis[emoji])
|
||||
|
||||
# Delete reactions from data
|
||||
description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove)
|
||||
reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove])
|
||||
target.refresh()
|
||||
|
||||
# Ack
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Roles deactivated",
|
||||
description=description
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
return
|
||||
|
||||
# Any remaining arguments should be emoji specifications with optional role
|
||||
# Parse these now
|
||||
given_emojis = {} # Map PartialEmoji -> Optional[Role]
|
||||
existing_emojis = set() # Set of existing reaction emoji identifiers
|
||||
|
||||
if ctx.args:
|
||||
# First build the list of custom emojis we can accept by name
|
||||
# We do this by reverse precedence, so the highest priority emojis are added last
|
||||
custom_emojis = []
|
||||
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
|
||||
if target:
|
||||
custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target
|
||||
custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message
|
||||
|
||||
# Filter out the built in emojis and those without a name
|
||||
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
|
||||
|
||||
# Build the maps to lookup provided custom emojis
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
|
||||
|
||||
# Now parse the provided emojis
|
||||
# Assume that all-unicode strings are built-in emojis
|
||||
# We can't assume much else unless we have a list of such emojis
|
||||
splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split)
|
||||
splits = (split.split(maxsplit=1) for split in splits if split)
|
||||
arg_emoji_strings = {
|
||||
split[0]: split[1] if len(split) > 1 else None
|
||||
for split in splits
|
||||
} # emoji_str -> Optional[role_str]
|
||||
|
||||
arg_emoji_map = {}
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji!".format(emoji_str)
|
||||
)
|
||||
else:
|
||||
arg_emoji_map[emoji] = role_str
|
||||
|
||||
# Final pass extracts roles
|
||||
# If any new emojis were provided, their roles should be specified, we enforce this during role parsing
|
||||
# First collect the existing emoji strings
|
||||
if target:
|
||||
for reaction in target.reactions:
|
||||
emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id
|
||||
existing_emojis.add(emoji_id)
|
||||
|
||||
# Now parse and assign the roles, building the final map
|
||||
for emoji, role_str in arg_emoji_map.items():
|
||||
emoji_id = emoji.name if emoji.id is None else emoji.id
|
||||
role = None
|
||||
if role_str:
|
||||
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
|
||||
elif emoji_id not in existing_emojis:
|
||||
return await ctx.error_reply(
|
||||
"New emoji {} was given without an associated role!".format(emoji)
|
||||
)
|
||||
given_emojis[emoji] = role
|
||||
|
||||
# Next manage target creation or emoji editing, if required
|
||||
if target is None:
|
||||
# Reaction message creation wizard
|
||||
# Confirm that they want to create a new reaction role message.
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
question="Do you want to set up new reaction roles for [this message]({})?".format(
|
||||
message.jump_url
|
||||
),
|
||||
timeout_msg="Prompt timed out, no reaction roles created.",
|
||||
cancel_msg="Reaction Role creation cancelled."
|
||||
)
|
||||
|
||||
# Continue with creation
|
||||
# Obtain emojis if not already provided
|
||||
if not given_emojis:
|
||||
# Prompt for the initial emojis
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="What reaction roles would you like to add?",
|
||||
description=(
|
||||
"Please now type the reaction roles you would like to add "
|
||||
"in the form `emoji role`, where `role` is given by partial name or id. For example:"
|
||||
"```{}```".format(example_str)
|
||||
)
|
||||
)
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
# Wait for a response
|
||||
def check(msg):
|
||||
return msg.author == ctx.author and msg.channel == ctx.ch and msg.content
|
||||
|
||||
try:
|
||||
reply = await ctx.client.wait_for('message', check=check, timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description="Prompt timed out, no reaction roles created."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
rolestrs = reply.content
|
||||
|
||||
try:
|
||||
await reply.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Attempt to parse the emojis
|
||||
# First build the list of custom emojis we can accept by name
|
||||
custom_emojis = []
|
||||
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
|
||||
custom_emojis.extend(
|
||||
r.emoji for r in message.reactions if r.custom_emoji
|
||||
) # Actual reactions on the message
|
||||
|
||||
# Filter out the built in emojis and those without a name
|
||||
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
|
||||
|
||||
# Build the maps to lookup provided custom emojis
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
|
||||
|
||||
# Now parse the provided emojis
|
||||
# Assume that all-unicode strings are built-in emojis
|
||||
# We can't assume much else unless we have a list of such emojis
|
||||
splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split)
|
||||
splits = (split.split(maxsplit=1) for split in splits if split)
|
||||
arg_emoji_strings = {
|
||||
split[0]: split[1] if len(split) > 1 else None
|
||||
for split in splits
|
||||
} # emoji_str -> Optional[role_str]
|
||||
|
||||
# Check all the emojis have roles associated
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
if role_str is None:
|
||||
return await ctx.error_reply(
|
||||
"No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str)
|
||||
)
|
||||
|
||||
# Parse the provided roles and emojis
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji!".format(emoji_str)
|
||||
)
|
||||
else:
|
||||
given_emojis[emoji] = await ctx.find_role(
|
||||
role_str,
|
||||
create=True,
|
||||
interactive=True,
|
||||
allow_notfound=False
|
||||
)
|
||||
|
||||
if len(given_emojis) > 20:
|
||||
return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.")
|
||||
|
||||
# Create the ReactionRoleMessage
|
||||
target = ReactionRoleMessage.create(
|
||||
message.id,
|
||||
message.guild.id,
|
||||
message.channel.id
|
||||
)
|
||||
|
||||
# Insert the reaction data directly
|
||||
reaction_role_reactions.insert_many(
|
||||
*((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()),
|
||||
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
|
||||
)
|
||||
|
||||
# Refresh the message to pick up the new reactions
|
||||
target.refresh()
|
||||
|
||||
# Add the reactions to the message, if possible
|
||||
existing_reactions = set(
|
||||
reaction.emoji if not reaction.custom_emoji else
|
||||
(reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id)
|
||||
for reaction in message.reactions
|
||||
)
|
||||
missing = [
|
||||
reaction.emoji for reaction in target.reactions
|
||||
if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions
|
||||
]
|
||||
if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id):
|
||||
# We can add the missing emojis
|
||||
for emoji in missing:
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except discord.HTTPException:
|
||||
break
|
||||
else:
|
||||
missing = []
|
||||
|
||||
# Ack the creation
|
||||
ack_msg = "Created `{}` new reaction roles on [this message]({})!".format(
|
||||
len(target.reactions),
|
||||
target.message_link
|
||||
)
|
||||
if missing:
|
||||
ack_msg += "\nPlease add the missing reactions to the message!"
|
||||
await ctx.embed_reply(
|
||||
ack_msg
|
||||
)
|
||||
elif given_emojis:
|
||||
# Update the target reactions
|
||||
# Create a map of the emojis that need to be added or updated
|
||||
needs_update = {
|
||||
emoji: role for emoji, role in given_emojis.items() if role
|
||||
}
|
||||
|
||||
# Fetch the existing target emojis to split the roles into inserts and updates
|
||||
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
|
||||
|
||||
# Handle the new roles
|
||||
insert_targets = {
|
||||
emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis
|
||||
}
|
||||
if insert_targets:
|
||||
if len(insert_targets) + len(target_emojis) > 20:
|
||||
return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!")
|
||||
reaction_role_reactions.insert_many(
|
||||
*(
|
||||
(message.id, role.id, emoji.name, emoji.id, emoji.animated)
|
||||
for emoji, role in insert_targets.items()
|
||||
),
|
||||
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
|
||||
)
|
||||
# Handle the updated roles
|
||||
update_targets = {
|
||||
target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis
|
||||
}
|
||||
if update_targets:
|
||||
reaction_role_reactions.update_many(
|
||||
*((role.id, reaction.reactionid) for reaction, role in update_targets.items()),
|
||||
set_keys=('roleid',),
|
||||
where_keys=('reactionid',),
|
||||
)
|
||||
|
||||
# Finally, refresh to load the new reactions
|
||||
target.refresh()
|
||||
|
||||
# Now that the target is created/updated, all the provided emojis should be reactions
|
||||
given_reactions = []
|
||||
if given_emojis:
|
||||
# Make a map of the existing reactions
|
||||
existing_reactions = {
|
||||
reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction
|
||||
for reaction in target.reactions
|
||||
}
|
||||
given_reactions = [
|
||||
existing_reactions[emoji.name if emoji.id is None else emoji.id]
|
||||
for emoji in given_emojis
|
||||
]
|
||||
|
||||
# Handle message setting updates
|
||||
update_lines = [] # Setting update lines to display
|
||||
update_columns = {} # Message data columns to update
|
||||
for flag in _message_setting_flags:
|
||||
if flags[flag]:
|
||||
setting_class = _message_setting_flags[flag]
|
||||
try:
|
||||
setting = await setting_class.parse(target.messageid, ctx, flags[flag])
|
||||
except UserInputError as e:
|
||||
return await ctx.error_reply(
|
||||
"{} {}\nNo settings were modified.".format(cross, e.msg),
|
||||
title="Couldn't save settings!"
|
||||
)
|
||||
else:
|
||||
update_lines.append(
|
||||
"{} {}".format(tick, setting.success_response)
|
||||
)
|
||||
update_columns[setting._data_column] = setting.data
|
||||
if update_columns:
|
||||
# First write the data
|
||||
reaction_role_messages.update_where(
|
||||
update_columns,
|
||||
messageid=target.messageid
|
||||
)
|
||||
# Then ack the setting update
|
||||
if len(update_lines) > 1:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Role message settings updated!",
|
||||
description='\n'.join(update_lines)
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
description=update_lines[0]
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# Handle reaction setting updates
|
||||
update_lines = [] # Setting update lines to display
|
||||
update_columns = {} # Message data columns to update, for all given reactions
|
||||
reactions = given_reactions or target.reactions
|
||||
for flag in _reaction_setting_flags:
|
||||
for reaction in reactions:
|
||||
if flags[flag]:
|
||||
setting_class = _reaction_setting_flags[flag]
|
||||
try:
|
||||
setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag])
|
||||
except UserInputError as e:
|
||||
return await ctx.error_reply(
|
||||
"{} {}\nNo reaction roles were modified.".format(cross, e.msg),
|
||||
title="Couldn't save reaction role settings!",
|
||||
)
|
||||
else:
|
||||
update_lines.append(
|
||||
setting.success_response.format(reaction=reaction)
|
||||
)
|
||||
update_columns[setting._data_column] = setting.data
|
||||
if update_columns:
|
||||
# First write the data
|
||||
reaction_role_reactions.update_where(
|
||||
update_columns,
|
||||
reactionid=[reaction.reactionid for reaction in reactions]
|
||||
)
|
||||
# Then ack the setting update
|
||||
if len(update_lines) > 1:
|
||||
blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)]
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Role settings updated!",
|
||||
description=block
|
||||
) for block in blocks
|
||||
]
|
||||
await ctx.pager(embeds)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
description=update_lines[0]
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# Show the reaction role message summary
|
||||
# Build the reaction fields
|
||||
reaction_fields = [] # List of tuples (name, value)
|
||||
for reaction in target.reactions:
|
||||
reaction_fields.append(
|
||||
(
|
||||
"{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''),
|
||||
"<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated())
|
||||
)
|
||||
)
|
||||
|
||||
# Build the final setting pages
|
||||
description = (
|
||||
"{settings_table}\n"
|
||||
"To update a message setting: `{prefix}rroles messageid --setting value`\n"
|
||||
"To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n"
|
||||
"See examples and more usage information with `{prefix}help rroles`.\n"
|
||||
"**(!) Replace the `setting` with one of the settings on this page.**\n"
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
settings_table=target.settings.tabulated()
|
||||
)
|
||||
|
||||
field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)]
|
||||
page_count = len(field_blocks)
|
||||
embeds = []
|
||||
for i, block in enumerate(field_blocks):
|
||||
title = "Reaction role settings for message id `{}`".format(target.messageid)
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description=description
|
||||
).set_author(
|
||||
name="Click to jump to message",
|
||||
url=target.message_link
|
||||
)
|
||||
for name, value in block:
|
||||
embed.add_field(name=name, value=value)
|
||||
if page_count > 1:
|
||||
embed.set_footer(text="Page {} of {}".format(i+1, page_count))
|
||||
embeds.append(embed)
|
||||
|
||||
# Finally, send the reaction role information
|
||||
await ctx.pager(embeds)
|
||||
@@ -0,0 +1,22 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
reaction_role_messages = RowTable(
|
||||
'reaction_role_messages',
|
||||
('messageid', 'guildid', 'channelid',
|
||||
'enabled',
|
||||
'required_role', 'allow_deselction',
|
||||
'max_obtainable', 'allow_refunds',
|
||||
'event_log'),
|
||||
'messageid'
|
||||
)
|
||||
|
||||
|
||||
reaction_role_reactions = RowTable(
|
||||
'reaction_role_reactions',
|
||||
('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'),
|
||||
'reactionid'
|
||||
)
|
||||
|
||||
|
||||
reaction_role_expiring = Table('reaction_role_expiring')
|
||||
172
bot/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py
Normal file
172
bot/modules/pending-rewrite/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 = _expiring[key] - utc_now().timestamp() if key else None
|
||||
await asyncio.wait_for(_wakeup_event.wait(), timeout=diff)
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout means next doesn't exist or is ready to expire
|
||||
if key and key in _expiring and _expiring[key] <= utc_now().timestamp() + 1:
|
||||
_expiring.pop(key)
|
||||
asyncio.create_task(_expire(key))
|
||||
except Exception:
|
||||
# This should be impossible, but catch and log anyway
|
||||
client.log(
|
||||
"Exception occurred while tracking reaction role expiry. Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_EXPIRY",
|
||||
level=logging.ERROR
|
||||
)
|
||||
else:
|
||||
# Wakeup event means that we should recalculate next
|
||||
_wakeup_event.clear()
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_expiry_tracker(client):
|
||||
"""
|
||||
Launch the role expiry tracker.
|
||||
"""
|
||||
asyncio.create_task(_expiry_tracker(client))
|
||||
client.log("Reaction role expiry tracker launched.", context="REACTION_ROLE_EXPIRY")
|
||||
|
||||
|
||||
@module.init_task
|
||||
def load_expiring_roles(client):
|
||||
"""
|
||||
Initialise the expiring reaction role map, and attach it to the client.
|
||||
"""
|
||||
rows = reaction_role_expiring.select_where()
|
||||
_expiring.clear()
|
||||
_expiring.update({(row['guildid'], row['userid'], row['roleid']): row['expiry'].timestamp() for row in rows})
|
||||
client.objects['expiring_reaction_roles'] = _expiring
|
||||
if _expiring:
|
||||
client.log(
|
||||
"Loaded {} expiring reaction roles.".format(len(_expiring)),
|
||||
context="REACTION_ROLE_EXPIRY"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Reaction_Roles")
|
||||
@@ -0,0 +1,257 @@
|
||||
from utils.lib import DotDict
|
||||
from wards import guild_admin
|
||||
from settings import ObjectSettings, ColumnData, Setting
|
||||
import settings.setting_types as setting_types
|
||||
|
||||
from .data import reaction_role_messages, reaction_role_reactions
|
||||
|
||||
|
||||
class RoleMessageSettings(ObjectSettings):
|
||||
settings = DotDict()
|
||||
|
||||
|
||||
class RoleMessageSetting(ColumnData, Setting):
|
||||
_table_interface = reaction_role_messages
|
||||
_id_column = 'messageid'
|
||||
_create_row = False
|
||||
|
||||
write_ward = guild_admin
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class required_role(setting_types.Role, RoleMessageSetting):
|
||||
attr_name = 'required_role'
|
||||
_data_column = 'required_role'
|
||||
|
||||
display_name = "required_role"
|
||||
desc = "Role required to use the reaction roles."
|
||||
|
||||
long_desc = (
|
||||
"Members will be required to have the specified role to use the reactions on this message."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members need {} to use these reaction roles.".format(self.formatted)
|
||||
else:
|
||||
return "All members can now use these reaction roles."
|
||||
|
||||
@classmethod
|
||||
def _get_guildid(cls, id: int, **kwargs):
|
||||
return reaction_role_messages.fetch(id).guildid
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class removable(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'removable'
|
||||
_data_column = 'removable'
|
||||
|
||||
display_name = "removable"
|
||||
desc = "Whether the role is removable by deselecting the reaction."
|
||||
|
||||
long_desc = (
|
||||
"If enabled, the role will be removed when the reaction is deselected."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members will be able to remove roles by unreacting."
|
||||
else:
|
||||
return "Members will not be able to remove the reaction roles."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class maximum(setting_types.Integer, RoleMessageSetting):
|
||||
attr_name = 'maximum'
|
||||
_data_column = 'maximum'
|
||||
|
||||
display_name = "maximum"
|
||||
desc = "The maximum number of roles a member can get from this message."
|
||||
|
||||
long_desc = (
|
||||
"The maximum number of roles that a member can get from this message. "
|
||||
"They will be notified by DM if they attempt to add more.\n"
|
||||
"The `removable` setting should generally be enabled with this setting."
|
||||
)
|
||||
|
||||
accepts = "An integer number of roles, or `None` to remove the maximum."
|
||||
|
||||
_min = 0
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "No maximum!"
|
||||
else:
|
||||
return "`{}`".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members can get a maximum of `{}` roles from this message.".format(self.value)
|
||||
else:
|
||||
return "Members can now get all the roles from this mesage."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class refunds(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'refunds'
|
||||
_data_column = 'refunds'
|
||||
|
||||
display_name = "refunds"
|
||||
desc = "Whether a user will be refunded when they deselect a role."
|
||||
|
||||
long_desc = (
|
||||
"Whether to give the user a refund when they deselect a role by reaction. "
|
||||
"This has no effect if `removable` is not enabled, or if the role removed has no cost."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members will get a refund when they remove a role."
|
||||
else:
|
||||
return "Members will not get a refund when they remove a role."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class default_price(setting_types.Integer, RoleMessageSetting):
|
||||
attr_name = 'default_price'
|
||||
_data_column = 'default_price'
|
||||
|
||||
display_name = "default_price"
|
||||
desc = "Default price of reaction roles on this message."
|
||||
|
||||
long_desc = (
|
||||
"Reaction roles on this message will have this cost if they do not have an individual price set."
|
||||
)
|
||||
|
||||
accepts = "An integer number of coins. Use `0` or `None` to make roles free by default."
|
||||
|
||||
_default = 0
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if not data:
|
||||
return "Free"
|
||||
else:
|
||||
return "`{}` coins".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Reaction roles on this message will cost `{}` coins by default.".format(self.value)
|
||||
else:
|
||||
return "Reaction roles on this message will be free by default."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class log(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'log'
|
||||
_data_column = 'event_log'
|
||||
|
||||
display_name = "log"
|
||||
desc = "Whether to log reaction role usage in the event log."
|
||||
|
||||
long_desc = (
|
||||
"When enabled, roles added or removed with reactions will be logged in the configured event log."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Role updates will now be logged."
|
||||
else:
|
||||
return "Role updates will not be logged."
|
||||
|
||||
|
||||
class ReactionSettings(ObjectSettings):
|
||||
settings = DotDict()
|
||||
|
||||
|
||||
class ReactionSetting(ColumnData, Setting):
|
||||
_table_interface = reaction_role_reactions
|
||||
_id_column = 'reactionid'
|
||||
_create_row = False
|
||||
|
||||
write_ward = guild_admin
|
||||
|
||||
|
||||
@ReactionSettings.attach_setting
|
||||
class price(setting_types.Integer, ReactionSetting):
|
||||
attr_name = 'price'
|
||||
_data_column = 'price'
|
||||
|
||||
display_name = "price"
|
||||
desc = "Price of this reaction role (may be negative)."
|
||||
|
||||
long_desc = (
|
||||
"The number of coins that will be deducted from the user when this reaction is used.\n"
|
||||
"The number may be negative, in order to give a reward when the member choses the reaction."
|
||||
)
|
||||
|
||||
accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default."
|
||||
_max = 2 ** 20
|
||||
|
||||
@property
|
||||
def default(self):
|
||||
"""
|
||||
The default price is given by the ReactionMessage price setting.
|
||||
"""
|
||||
return default_price.get(self._table_interface.fetch(self.id).messageid).value
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if not data:
|
||||
return "Free"
|
||||
else:
|
||||
return "`{}` coins".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value is not None:
|
||||
return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value)
|
||||
else:
|
||||
return "{reaction.emoji} {reaction.role.mention} is now free."
|
||||
|
||||
|
||||
@ReactionSettings.attach_setting
|
||||
class duration(setting_types.Duration, ReactionSetting):
|
||||
attr_name = 'duration'
|
||||
_data_column = 'timeout'
|
||||
|
||||
display_name = "duration"
|
||||
desc = "How long this reaction role will last."
|
||||
|
||||
long_desc = (
|
||||
"If set, the reaction role will be removed after the configured duration. "
|
||||
"Note that this does not affect existing members with the role, or existing expiries."
|
||||
)
|
||||
|
||||
_default_multiplier = 3600
|
||||
_show_days = True
|
||||
_min = 600
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Permanent"
|
||||
else:
|
||||
return super()._format_data(id, data, **kwargs)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value is not None:
|
||||
return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format(
|
||||
self.formatted
|
||||
)
|
||||
else:
|
||||
return "{reaction.emoji} {reaction.role.mention} will not expire."
|
||||
@@ -0,0 +1,590 @@
|
||||
import asyncio
|
||||
from codecs import ignore_errors
|
||||
import logging
|
||||
import traceback
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from typing import List, Mapping, Optional
|
||||
from cachetools import LFUCache
|
||||
|
||||
import discord
|
||||
from discord import PartialEmoji
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from data import Row
|
||||
from data.conditions import THIS_SHARD
|
||||
from utils.lib import utc_now
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import reaction_role_messages, reaction_role_reactions
|
||||
from .settings import RoleMessageSettings, ReactionSettings
|
||||
from .expiry import schedule_expiry, cancel_expiry
|
||||
|
||||
|
||||
class ReactionRoleReaction:
|
||||
"""
|
||||
Light data class representing a reaction role reaction.
|
||||
"""
|
||||
__slots__ = ('reactionid', '_emoji', '_message', '_role')
|
||||
|
||||
def __init__(self, reactionid, message=None, **kwargs):
|
||||
self.reactionid = reactionid
|
||||
self._message: ReactionRoleMessage = None
|
||||
self._role = None
|
||||
self._emoji = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction':
|
||||
"""
|
||||
Create a new ReactionRoleReaction with the provided attributes.
|
||||
`emoji` sould be provided as a PartialEmoji.
|
||||
`kwargs` are passed transparently to the `insert` method.
|
||||
"""
|
||||
row = reaction_role_reactions.create_row(
|
||||
messageid=messageid,
|
||||
roleid=roleid,
|
||||
emoji_name=emoji.name,
|
||||
emoji_id=emoji.id,
|
||||
emoji_animated=emoji.animated,
|
||||
**kwargs
|
||||
)
|
||||
return cls(row.reactionid, message=message)
|
||||
|
||||
@property
|
||||
def emoji(self) -> PartialEmoji:
|
||||
if self._emoji is None:
|
||||
data = self.data
|
||||
self._emoji = PartialEmoji(
|
||||
name=data.emoji_name,
|
||||
animated=data.emoji_animated,
|
||||
id=data.emoji_id,
|
||||
)
|
||||
return self._emoji
|
||||
|
||||
@property
|
||||
def data(self) -> Row:
|
||||
return reaction_role_reactions.fetch(self.reactionid)
|
||||
|
||||
@property
|
||||
def settings(self) -> ReactionSettings:
|
||||
return ReactionSettings(self.reactionid)
|
||||
|
||||
@property
|
||||
def reaction_message(self):
|
||||
if self._message is None:
|
||||
self._message = ReactionRoleMessage.fetch(self.data.messageid)
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def role(self):
|
||||
if self._role is None:
|
||||
guild = self.reaction_message.guild
|
||||
if guild:
|
||||
self._role = guild.get_role(self.data.roleid)
|
||||
return self._role
|
||||
|
||||
|
||||
class ReactionRoleMessage:
|
||||
"""
|
||||
Light data class representing a reaction role message.
|
||||
Primarily acts as an interface to the corresponding Settings.
|
||||
"""
|
||||
__slots__ = ('messageid', '_message')
|
||||
|
||||
# Full live messageid cache for this client. Should always be up to date.
|
||||
_messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message
|
||||
|
||||
# Reaction cache for the live messages. Least frequently used, will be fetched on demand.
|
||||
_reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions
|
||||
|
||||
# User-keyed locks so we only handle one reaction per user at a time
|
||||
_locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock
|
||||
|
||||
def __init__(self, messageid):
|
||||
self.messageid = messageid
|
||||
self._message = None
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, messageid) -> 'ReactionRoleMessage':
|
||||
"""
|
||||
Fetch the ReactionRoleMessage for the provided messageid.
|
||||
Returns None if the messageid is not registered.
|
||||
"""
|
||||
# Since the cache is assumed to be always up to date, just pass to fetch-from-cache.
|
||||
return cls._messages.get(messageid, None)
|
||||
|
||||
@classmethod
|
||||
def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage':
|
||||
"""
|
||||
Create a ReactionRoleMessage with the given `messageid`.
|
||||
Other `kwargs` are passed transparently to `insert`.
|
||||
"""
|
||||
# Insert the data
|
||||
reaction_role_messages.create_row(
|
||||
messageid=messageid,
|
||||
guildid=guildid,
|
||||
channelid=channelid,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Create the ReactionRoleMessage
|
||||
rmsg = cls(messageid)
|
||||
|
||||
# Add to the global cache
|
||||
cls._messages[messageid] = rmsg
|
||||
|
||||
# Return the constructed ReactionRoleMessage
|
||||
return rmsg
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete this ReactionRoleMessage.
|
||||
"""
|
||||
# Remove message from cache
|
||||
self._messages.pop(self.messageid, None)
|
||||
|
||||
# Remove reactions from cache
|
||||
reactionids = [reaction.reactionid for reaction in self.reactions]
|
||||
[self._reactions.pop(reactionid, None) for reactionid in reactionids]
|
||||
|
||||
# Remove message from data
|
||||
reaction_role_messages.delete_where(messageid=self.messageid)
|
||||
|
||||
@property
|
||||
def data(self) -> Row:
|
||||
"""
|
||||
Data row associated with this Message.
|
||||
Passes directly to the RowTable cache.
|
||||
Should not generally be used directly, use the settings interface instead.
|
||||
"""
|
||||
return reaction_role_messages.fetch(self.messageid)
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
"""
|
||||
RoleMessageSettings associated to this Message.
|
||||
"""
|
||||
return RoleMessageSettings(self.messageid)
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refresh the reaction cache for this message.
|
||||
Returns the generated `ReactionRoleReaction`s for convenience.
|
||||
"""
|
||||
# Fetch reactions and pre-populate reaction cache
|
||||
rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC")
|
||||
reactions = [ReactionRoleReaction(row.reactionid) for row in rows]
|
||||
self._reactions[self.messageid] = reactions
|
||||
return reactions
|
||||
|
||||
@property
|
||||
def reactions(self) -> List[ReactionRoleReaction]:
|
||||
"""
|
||||
Returns the list of active reactions for this message, as `ReactionRoleReaction`s.
|
||||
Lazily fetches the reactions from data if they have not been loaded.
|
||||
"""
|
||||
reactions = self._reactions.get(self.messageid, None)
|
||||
if reactions is None:
|
||||
reactions = self.refresh()
|
||||
return reactions
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""
|
||||
Whether this Message is enabled.
|
||||
Passes directly to data for efficiency.
|
||||
"""
|
||||
return self.data.enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self.data.enabled = value
|
||||
|
||||
# Discord properties
|
||||
@property
|
||||
def guild(self) -> discord.Guild:
|
||||
return client.get_guild(self.data.guildid)
|
||||
|
||||
@property
|
||||
def channel(self) -> discord.TextChannel:
|
||||
return client.get_channel(self.data.channelid)
|
||||
|
||||
async def fetch_message(self) -> discord.Message:
|
||||
if self._message:
|
||||
return self._message
|
||||
|
||||
channel = self.channel
|
||||
if channel:
|
||||
try:
|
||||
self._message = await channel.fetch_message(self.messageid)
|
||||
return self._message
|
||||
except discord.NotFound:
|
||||
# The message no longer exists
|
||||
# TODO: Cache and data cleanup? Or allow moving after death?
|
||||
pass
|
||||
|
||||
@property
|
||||
def message(self) -> Optional[discord.Message]:
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def message_link(self) -> str:
|
||||
"""
|
||||
Jump link tho the reaction message.
|
||||
"""
|
||||
return 'https://discord.com/channels/{}/{}/{}'.format(
|
||||
self.data.guildid,
|
||||
self.data.channelid,
|
||||
self.messageid
|
||||
)
|
||||
|
||||
# Event handlers
|
||||
async def process_raw_reaction_add(self, payload):
|
||||
"""
|
||||
Process a general reaction add payload.
|
||||
"""
|
||||
event_log = GuildSettings(self.guild.id).event_log
|
||||
async with self._locks[payload.user_id]:
|
||||
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
|
||||
if reaction:
|
||||
# User pressed a live reaction. Process!
|
||||
member = payload.member
|
||||
lion = Lion.fetch(member.guild.id, member.id)
|
||||
role = reaction.role
|
||||
if reaction.role and (role not in member.roles):
|
||||
# Required role check, make sure the user has the required role, if set.
|
||||
required_role = self.settings.required_role.value
|
||||
if required_role and required_role not in member.roles:
|
||||
# Silently remove their reaction
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
# Maximum check, check whether the user already has too many roles from this message.
|
||||
maximum = self.settings.maximum.value
|
||||
if maximum is not None:
|
||||
# Fetch the number of applicable roles the user has
|
||||
roleids = set(reaction.data.roleid for reaction in self.reactions)
|
||||
member_roleids = set(role.id for role in member.roles)
|
||||
if len(roleids.intersection(member_roleids)) >= maximum:
|
||||
# Notify the user
|
||||
embed = discord.Embed(
|
||||
title="Maximum group roles reached!",
|
||||
description=(
|
||||
"Couldn't give you **{}**, "
|
||||
"because you already have `{}` roles from this group!".format(
|
||||
role.name,
|
||||
maximum
|
||||
)
|
||||
)
|
||||
)
|
||||
# Silently try to notify the user
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
# Silently remove the reaction
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
# Economy hook, check whether the user can pay for the role.
|
||||
price = reaction.settings.price.value
|
||||
if price and price > lion.coins:
|
||||
# They can't pay!
|
||||
# Build the can't pay embed
|
||||
embed = discord.Embed(
|
||||
title="Insufficient funds!",
|
||||
description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format(
|
||||
role.name,
|
||||
price,
|
||||
lion.coins
|
||||
),
|
||||
colour=discord.Colour.red()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click here]({})".format(self.message_link)
|
||||
)
|
||||
# Try to send them the embed, ignore errors
|
||||
try:
|
||||
await member.send(
|
||||
embed=embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Remove their reaction, ignore errors
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
# Add the role
|
||||
try:
|
||||
await member.add_roles(
|
||||
role,
|
||||
atomic=True,
|
||||
reason="Adding reaction role."
|
||||
)
|
||||
except discord.Forbidden:
|
||||
event_log.log(
|
||||
"Insufficient permissions to give {} the [reaction role]({}) {}".format(
|
||||
member.mention,
|
||||
self.message_link,
|
||||
role.mention,
|
||||
),
|
||||
title="Failed to add reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
except discord.HTTPException:
|
||||
event_log.log(
|
||||
"Something went wrong while adding the [reaction role]({}) "
|
||||
"{} to {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Failed to add reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
client.log(
|
||||
"Unexpected HTTPException encountered while adding '{}' (rid:{}) to "
|
||||
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
|
||||
role.name,
|
||||
role.id,
|
||||
member,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_ADD",
|
||||
level=logging.WARNING
|
||||
)
|
||||
else:
|
||||
# Charge the user and notify them, if the price is set
|
||||
if price:
|
||||
lion.addCoins(-price)
|
||||
# Notify the user of their purchase
|
||||
embed = discord.Embed(
|
||||
title="Purchase successful!",
|
||||
description="You have purchased **{}** for `{}` coins!".format(
|
||||
role.name,
|
||||
price
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click Here]({})".format(self.message_link)
|
||||
)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Schedule the expiry, if required
|
||||
duration = reaction.settings.duration.value
|
||||
if duration:
|
||||
expiry = utc_now() + datetime.timedelta(seconds=duration)
|
||||
schedule_expiry(self.guild.id, member.id, role.id, expiry, reaction.reactionid)
|
||||
else:
|
||||
expiry = None
|
||||
|
||||
# Log the role modification if required
|
||||
if self.settings.log.value:
|
||||
event_log.log(
|
||||
"Added [reaction role]({}) {} "
|
||||
"to {}{}.{}".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention,
|
||||
" for `{}` coins".format(price) if price else '',
|
||||
"\nThis role will expire at <t:{:.0f}>.".format(
|
||||
expiry.timestamp()
|
||||
) if expiry else ''
|
||||
),
|
||||
title="Reaction Role Added"
|
||||
)
|
||||
|
||||
async def process_raw_reaction_remove(self, payload):
|
||||
"""
|
||||
Process a general reaction remove payload.
|
||||
"""
|
||||
if self.settings.removable.value:
|
||||
event_log = GuildSettings(self.guild.id).event_log
|
||||
async with self._locks[payload.user_id]:
|
||||
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
|
||||
if reaction:
|
||||
# User removed a live reaction. Process!
|
||||
member = self.guild.get_member(payload.user_id)
|
||||
role = reaction.role
|
||||
if member and not member.bot and role and (role in member.roles):
|
||||
# Check whether they have the required role, if set
|
||||
required_role = self.settings.required_role.value
|
||||
if required_role and required_role not in member.roles:
|
||||
# Ignore the reaction removal
|
||||
return
|
||||
|
||||
try:
|
||||
await member.remove_roles(
|
||||
role,
|
||||
atomic=True,
|
||||
reason="Removing reaction role."
|
||||
)
|
||||
except discord.Forbidden:
|
||||
event_log.log(
|
||||
"Insufficient permissions to remove "
|
||||
"the [reaction role]({}) {} from {}".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention,
|
||||
),
|
||||
title="Failed to remove reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
except discord.HTTPException:
|
||||
event_log.log(
|
||||
"Something went wrong while removing the [reaction role]({}) "
|
||||
"{} from {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Failed to remove reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
client.log(
|
||||
"Unexpected HTTPException encountered while removing '{}' (rid:{}) from "
|
||||
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
|
||||
role.name,
|
||||
role.id,
|
||||
member,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_RM",
|
||||
level=logging.WARNING
|
||||
)
|
||||
else:
|
||||
# Economy hook, handle refund if required
|
||||
price = reaction.settings.price.value
|
||||
refund = self.settings.refunds.value
|
||||
if price and refund:
|
||||
# Give the user the refund
|
||||
lion = Lion.fetch(self.guild.id, member.id)
|
||||
lion.addCoins(price)
|
||||
|
||||
# Notify the user
|
||||
embed = discord.Embed(
|
||||
title="Role sold",
|
||||
description=(
|
||||
"You sold the role **{}** for `{}` coins.".format(
|
||||
role.name,
|
||||
price
|
||||
)
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click Here]({})".format(self.message_link)
|
||||
)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Log role removal if required
|
||||
if self.settings.log.value:
|
||||
event_log.log(
|
||||
"Removed [reaction role]({}) {} "
|
||||
"from {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Reaction Role Removed"
|
||||
)
|
||||
|
||||
# Cancel any existing expiry
|
||||
cancel_expiry(self.guild.id, member.id, role.id)
|
||||
|
||||
|
||||
# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them
|
||||
# TODO: Handle RawMessageDelete event
|
||||
# TODO: Handle permission errors when fetching message in config
|
||||
|
||||
@client.add_after_event('raw_reaction_add')
|
||||
async def reaction_role_add(client, payload):
|
||||
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
|
||||
if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled:
|
||||
try:
|
||||
await reaction_message.process_raw_reaction_add(payload)
|
||||
except Exception:
|
||||
# Unknown exception, catch and log it.
|
||||
client.log(
|
||||
"Unhandled exception while handling reaction message payload: {}\n{}".format(
|
||||
payload,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_ADD",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('raw_reaction_remove')
|
||||
async def reaction_role_remove(client, payload):
|
||||
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
|
||||
if payload.guild_id and reaction_message and reaction_message.enabled:
|
||||
try:
|
||||
await reaction_message.process_raw_reaction_remove(payload)
|
||||
except Exception:
|
||||
# Unknown exception, catch and log it.
|
||||
client.log(
|
||||
"Unhandled exception while handling reaction message payload: {}\n{}".format(
|
||||
payload,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_RM",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@module.init_task
|
||||
def load_reaction_roles(client):
|
||||
"""
|
||||
Load the ReactionRoleMessages.
|
||||
"""
|
||||
rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD)
|
||||
ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows}
|
||||
65
bot/modules/pending-rewrite/guild_admin/statreset.py
Normal file
65
bot/modules/pending-rewrite/guild_admin/statreset.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from io import StringIO
|
||||
|
||||
import discord
|
||||
from wards import guild_admin
|
||||
from data import tables
|
||||
from core import Lion
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd("studyreset",
|
||||
desc="Perform a reset of the server's study statistics.",
|
||||
group="Guild Admin")
|
||||
@guild_admin()
|
||||
async def cmd_statreset(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}studyreset
|
||||
Description:
|
||||
Perform a complete reset of the server's study statistics.
|
||||
That is, deletes the tracked time of all members and removes their study badges.
|
||||
|
||||
This may be used to set "seasons" of study.
|
||||
|
||||
Before the reset, I will send a csv file with the current member statistics.
|
||||
|
||||
**This is not reversible.**
|
||||
"""
|
||||
if not await ctx.ask("Are you sure you want to reset the study time and badges for all members? "
|
||||
"**THIS IS NOT REVERSIBLE!**"):
|
||||
return
|
||||
# Build the data csv
|
||||
rows = tables.lions.select_where(
|
||||
select_columns=('userid', 'tracked_time', 'coins', 'workout_count', 'b.roleid AS badge_roleid'),
|
||||
_extra=(
|
||||
"LEFT JOIN study_badges b ON last_study_badgeid = b.badgeid "
|
||||
"WHERE members.guildid={}"
|
||||
).format(ctx.guild.id)
|
||||
)
|
||||
header = "userid, tracked_time, coins, workouts, rank_roleid\n"
|
||||
csv_rows = [
|
||||
','.join(str(data) for data in row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
with StringIO() as stats_file:
|
||||
stats_file.write(header)
|
||||
stats_file.write('\n'.join(csv_rows))
|
||||
stats_file.seek(0)
|
||||
|
||||
out_file = discord.File(stats_file, filename="guild_{}_member_statistics.csv".format(ctx.guild.id))
|
||||
await ctx.reply(file=out_file)
|
||||
|
||||
# Reset the statistics
|
||||
tables.lions.update_where(
|
||||
{'tracked_time': 0},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
Lion.sync()
|
||||
|
||||
await ctx.embed_reply(
|
||||
"The member study times have been reset!\n"
|
||||
"(It may take a while for the studybadges to update.)"
|
||||
)
|
||||
Reference in New Issue
Block a user