Merge branch 'python-rewrite' into reaction_roles
This commit is contained in:
@@ -74,6 +74,7 @@ lions = RowTable(
|
||||
'workout_count', 'last_workout_start',
|
||||
'last_study_badgeid',
|
||||
'video_warned',
|
||||
'_timestamp'
|
||||
),
|
||||
('guildid', 'userid'),
|
||||
cache=TTLCache(5000, ttl=60*5),
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytz
|
||||
|
||||
from meta import client
|
||||
from data import tables as tb
|
||||
from settings import UserSettings
|
||||
from settings import UserSettings, GuildSettings
|
||||
|
||||
|
||||
class Lion:
|
||||
@@ -41,7 +41,13 @@ class Lion:
|
||||
if key in cls._lions:
|
||||
return cls._lions[key]
|
||||
else:
|
||||
tb.lions.fetch_or_create(key)
|
||||
lion = tb.lions.fetch(key)
|
||||
if not lion:
|
||||
tb.lions.create_row(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
coins=GuildSettings(guildid).starting_funds.value
|
||||
)
|
||||
return cls(guildid, userid)
|
||||
|
||||
@property
|
||||
|
||||
@@ -40,6 +40,32 @@ def setting_initialisation(client):
|
||||
setting.init_task(client)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def preload_guild_configuration(client):
|
||||
"""
|
||||
Loads the plain guild configuration for all guilds the client is part of into data.
|
||||
"""
|
||||
guildids = [guild.id for guild in client.guilds]
|
||||
rows = client.data.guild_config.fetch_rows_where(guildid=guildids)
|
||||
client.log(
|
||||
"Preloaded guild configuration for {} guilds.".format(len(rows)),
|
||||
context="CORE_LOADING"
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def preload_studying_members(client):
|
||||
"""
|
||||
Loads the member data for all members who are currently in voice channels.
|
||||
"""
|
||||
userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members))
|
||||
rows = client.data.lions.fetch_rows_where(userid=userids)
|
||||
client.log(
|
||||
"Preloaded member data for {} members.".format(len(rows)),
|
||||
context="CORE_LOADING"
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_lion_sync_loop(client):
|
||||
asyncio.create_task(_lion_sync_loop())
|
||||
|
||||
@@ -62,7 +62,7 @@ def ensure_exclusive(ctx):
|
||||
|
||||
@module.cmd(
|
||||
name="rooms",
|
||||
desc="Book an accountability timeslot",
|
||||
desc="Schedule an accountability study session.",
|
||||
group="Productivity"
|
||||
)
|
||||
@in_guild()
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict
|
||||
from discord.utils import sleep_until
|
||||
|
||||
from meta import client
|
||||
from utils.interactive import discord_shield
|
||||
from data import NULL, NOTNULL, tables
|
||||
from data.conditions import LEQ
|
||||
from settings import GuildSettings
|
||||
@@ -167,7 +168,7 @@ async def turnover():
|
||||
to_update = [
|
||||
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), None, mem.slotid, mem.userid)
|
||||
for slot in last_slots for mem in slot.members.values()
|
||||
if mem.data.last_joined_at
|
||||
if mem.data and mem.data.last_joined_at
|
||||
]
|
||||
if to_update:
|
||||
accountability_members.update_many(
|
||||
@@ -177,6 +178,15 @@ async def turnover():
|
||||
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# Close all completed rooms, update data
|
||||
await asyncio.gather(*(slot.close() for slot in last_slots), return_exceptions=True)
|
||||
update_slots = [slot.data.slotid for slot in last_slots if slot.data]
|
||||
if update_slots:
|
||||
accountability_rooms.update_where(
|
||||
{'closed_at': utc_now()},
|
||||
slotid=update_slots
|
||||
)
|
||||
|
||||
# Rotate guild sessions
|
||||
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
|
||||
|
||||
@@ -184,7 +194,6 @@ async def turnover():
|
||||
# We could break up the session starting?
|
||||
|
||||
# Move members of the next session over to the session channel
|
||||
# This includes any members of the session just complete
|
||||
current_slots = [
|
||||
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
|
||||
if aguild.current_slot is not None
|
||||
@@ -206,21 +215,12 @@ async def turnover():
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Close all completed rooms, update data
|
||||
await asyncio.gather(*(slot.close() for slot in last_slots))
|
||||
update_slots = [slot.data.slotid for slot in last_slots if slot.data]
|
||||
if update_slots:
|
||||
accountability_rooms.update_where(
|
||||
{'closed_at': utc_now()},
|
||||
slotid=update_slots
|
||||
)
|
||||
|
||||
# Update session data of all members in new channels
|
||||
member_session_data = [
|
||||
(0, slot.start_time, mem.slotid, mem.userid)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.member.voice and mem.member.voice.channel == slot.channel
|
||||
if mem.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel
|
||||
]
|
||||
if member_session_data:
|
||||
accountability_members.update_many(
|
||||
@@ -460,3 +460,31 @@ async def unload_accountability(client):
|
||||
Save the current sessions and cancel the runloop in preparation for client shutdown.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def restore_accountability(client, member):
|
||||
"""
|
||||
Restore accountability channel permissions when a member rejoins the server, if applicable.
|
||||
"""
|
||||
aguild = AccountabilityGuild.cache.get(member.guild.id, None)
|
||||
if aguild:
|
||||
if aguild.current_slot and member.id in aguild.current_slot.members:
|
||||
# Restore member permission for current slot
|
||||
slot = aguild.current_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
if aguild.upcoming_slot and member.id in aguild.upcoming_slot.members:
|
||||
slot = aguild.upcoming_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
|
||||
@@ -2,4 +2,5 @@ from .module import module
|
||||
|
||||
from . import guild_config
|
||||
from . import statreset
|
||||
from . import new_members
|
||||
from . import reaction_roles
|
||||
|
||||
@@ -11,7 +11,7 @@ from .module import module
|
||||
|
||||
# Pages of configuration categories to display
|
||||
cat_pages = {
|
||||
'Administration': ('Meta', 'Guild Roles'),
|
||||
'Administration': ('Meta', 'Guild Roles', 'New Members'),
|
||||
'Moderation': ('Moderation', 'Video Channels'),
|
||||
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
|
||||
'Study Rooms': ('Rented Rooms', 'Accountability Rooms'),
|
||||
@@ -21,6 +21,7 @@ cat_pages = {
|
||||
descriptions = {
|
||||
}
|
||||
|
||||
|
||||
@module.cmd("config",
|
||||
desc="View and modify the server settings.",
|
||||
flags=('add', 'remove'),
|
||||
@@ -91,27 +92,27 @@ async def cmd_config(ctx, flags):
|
||||
)
|
||||
)
|
||||
|
||||
if len(parts) == 1:
|
||||
if len(parts) == 1 and not ctx.msg.attachments:
|
||||
# config <setting>
|
||||
# View config embed for provided setting
|
||||
await ctx.reply(embed=setting.get(ctx.guild.id).embed)
|
||||
await setting.get(ctx.guild.id).widget(ctx, flags=flags)
|
||||
else:
|
||||
# config <setting> <value>
|
||||
# Check the write ward
|
||||
if not await setting.write_ward.run(ctx):
|
||||
await ctx.error_reply(setting.msg)
|
||||
await ctx.error_reply(setting.write_ward.msg)
|
||||
|
||||
# Attempt to set config setting
|
||||
try:
|
||||
parsed = await setting.parse(ctx.guild.id, ctx, parts[1])
|
||||
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()
|
||||
colour=discord.Colour.red()
|
||||
))
|
||||
else:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('✅', setting.get(ctx.guild.id).success_response),
|
||||
Colour=discord.Colour.green()
|
||||
colour=discord.Colour.green()
|
||||
))
|
||||
|
||||
3
bot/modules/guild_admin/new_members/__init__.py
Normal file
3
bot/modules/guild_admin/new_members/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import settings
|
||||
from . import greetings
|
||||
from . import roles
|
||||
6
bot/modules/guild_admin/new_members/data.py
Normal file
6
bot/modules/guild_admin/new_members/data.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
autoroles = Table('autoroles')
|
||||
bot_autoroles = Table('bot_autoroles')
|
||||
past_member_roles = Table('past_member_roles')
|
||||
29
bot/modules/guild_admin/new_members/greetings.py
Normal file
29
bot/modules/guild_admin/new_members/greetings.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import discord
|
||||
from cmdClient.Context import 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/guild_admin/new_members/roles.py
Normal file
115
bot/modules/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/guild_admin/new_members/settings.py
Normal file
303
bot/modules/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 = "greeting_channel"
|
||||
desc = "Channel to send the greeting message in"
|
||||
|
||||
long_desc = (
|
||||
"Channel to post the `greeting_message` in when a new user joins the server. "
|
||||
"Accepts `DM` to indicate the greeting should be direct messaged to the new member."
|
||||
)
|
||||
_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 "Greeting messages are disabled."
|
||||
elif value == self.DMCHANNEL:
|
||||
return "Greeting messages will be sent via direct message."
|
||||
else:
|
||||
return "Greeting 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 = 'greeting_message'
|
||||
desc = "Greeting message sent to welcome new members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `greeting_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 greeting 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 = "Greeting message sent to returning members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `greeting_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 = 0
|
||||
|
||||
@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."
|
||||
@@ -40,15 +40,15 @@ bot_admin_group_order = (
|
||||
|
||||
# Help embed format
|
||||
# TODO: Add config fields for this
|
||||
title = "LionBot Command List"
|
||||
title = "StudyLion Command List"
|
||||
header = """
|
||||
Use `{ctx.best_prefix}help <cmd>` (e.g. `{ctx.best_prefix}help send`) to see how to use each command.
|
||||
Use `{ctx.best_prefix}help <command>` (e.g. `{ctx.best_prefix}help send`) to see how to use each command.
|
||||
"""
|
||||
|
||||
|
||||
@module.cmd("help",
|
||||
group="Meta",
|
||||
desc="LionBot command list.")
|
||||
desc="StudyLion command list.")
|
||||
async def cmd_help(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
|
||||
@@ -120,9 +120,9 @@ async def cmd_tickets(ctx, flags):
|
||||
|
||||
if not tickets:
|
||||
if filters:
|
||||
return ctx.embed_reply("There are no tickets with these criteria!")
|
||||
return await ctx.embed_reply("There are no tickets with these criteria!")
|
||||
else:
|
||||
return ctx.embed_reply("There are no moderation tickets in this server!")
|
||||
return await ctx.embed_reply("There are no moderation tickets in this server!")
|
||||
|
||||
tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True)
|
||||
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}
|
||||
|
||||
@@ -7,7 +7,7 @@ from .rooms import Room
|
||||
|
||||
@module.cmd(
|
||||
name="rent",
|
||||
desc="Rent a private study room!",
|
||||
desc="Rent a private study room with your friends!",
|
||||
group="Productivity",
|
||||
aliases=('add',)
|
||||
)
|
||||
|
||||
@@ -243,7 +243,7 @@ class Room:
|
||||
guild_settings.event_log.log(
|
||||
title="Failed to update study room permissions!",
|
||||
description=("An error occured while removing the "
|
||||
"following members from the private room {}.\n{}").format(
|
||||
"following members from the private room {}.\n{}").format(
|
||||
self.channel.mention,
|
||||
', '.join(member.mention for member in members)
|
||||
),
|
||||
@@ -282,3 +282,38 @@ async def load_rented_rooms(client):
|
||||
"Loaded {} private study channels.".format(len(rows)),
|
||||
context="LOAD_RENTED_ROOMS"
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def restore_room_permission(client, member):
|
||||
"""
|
||||
If a member has, or is part of, a private room when they rejoin, restore their permissions.
|
||||
"""
|
||||
# First check whether they own a room
|
||||
owned = Room.fetch(member.guild.id, member.id)
|
||||
if owned and owned.channel:
|
||||
# Restore their room permissions
|
||||
try:
|
||||
await owned.channel.set_permissions(
|
||||
member,
|
||||
overwrite=Room.owner_overwrite
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Then check if they are in any other rooms
|
||||
in_room_rows = rented_members.select_where(
|
||||
_extra="LEFT JOIN rented USING (channelid) WHERE userid={} AND guildid={}".format(
|
||||
member.id, member.guild.id
|
||||
)
|
||||
)
|
||||
for row in in_room_rows:
|
||||
room = Room.fetch(member.guild.id, row['ownerid'])
|
||||
if room and row['ownerid'] != member.id and room.channel:
|
||||
try:
|
||||
await room.channel.set_permissions(
|
||||
member,
|
||||
overwrite=Room.member_overwrite
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import settings
|
||||
from settings import GuildSettings
|
||||
from wards import guild_admin
|
||||
@@ -37,6 +39,34 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti
|
||||
else:
|
||||
return "Study time will now be counted in all channels."
|
||||
|
||||
@classmethod
|
||||
async def launch_task(cls, client):
|
||||
"""
|
||||
Launch initialisation step for the `untracked_channels` setting.
|
||||
|
||||
Pre-fill cache for the guilds with currently active voice channels.
|
||||
"""
|
||||
active_guildids = [
|
||||
guild.id
|
||||
for guild in client.guilds
|
||||
if any(channel.members for channel in guild.voice_channels)
|
||||
]
|
||||
if active_guildids:
|
||||
rows = cls._table_interface.select_where(
|
||||
guildid=active_guildids
|
||||
)
|
||||
cache = defaultdict(list)
|
||||
for row in rows:
|
||||
cache[row['guildid']].append(row['channelid'])
|
||||
cls._cache.update(cache)
|
||||
client.log(
|
||||
"Cached {} untracked channels for {} active guilds.".format(
|
||||
len(rows),
|
||||
len(cache)
|
||||
),
|
||||
context="UNTRACKED_CHANNELS"
|
||||
)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class hourly_reward(settings.Integer, settings.GuildSetting):
|
||||
|
||||
@@ -101,6 +101,8 @@ async def _study_tracker():
|
||||
|
||||
@module.launch_task
|
||||
async def launch_study_tracker(client):
|
||||
# First pre-load the untracked channels
|
||||
await admin.untracked_channels.launch_task(client)
|
||||
asyncio.create_task(_study_tracker())
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from .Tasklist import Tasklist
|
||||
|
||||
@module.cmd(
|
||||
name="todo",
|
||||
desc="Display and edit your personal TODO list.",
|
||||
desc="Display and edit your personal To-Do list.",
|
||||
group="Productivity",
|
||||
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==')
|
||||
)
|
||||
|
||||
@@ -51,6 +51,14 @@ class Setting:
|
||||
embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table)
|
||||
return embed
|
||||
|
||||
async def widget(self, ctx: Context, **kwargs):
|
||||
"""
|
||||
Show the setting widget for this setting.
|
||||
By default this displays the setting embed.
|
||||
Settings may override this if they need more complex widget context or logic.
|
||||
"""
|
||||
return await ctx.reply(embed=self.embed)
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
import asyncio
|
||||
import datetime
|
||||
import itertools
|
||||
from io import StringIO
|
||||
from enum import IntEnum
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -9,11 +12,14 @@ from cmdClient.Context import Context
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from meta import client
|
||||
from utils.lib import parse_dur, strfdur, strfdelta
|
||||
from utils.lib import parse_dur, strfdur, strfdelta, prop_tabulate, multiple_replace
|
||||
|
||||
from .base import UserInputError
|
||||
|
||||
|
||||
preview_emoji = '🔍'
|
||||
|
||||
|
||||
class SettingType:
|
||||
"""
|
||||
Abstract class representing a setting type.
|
||||
@@ -687,6 +693,209 @@ class Duration(SettingType):
|
||||
return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days))
|
||||
|
||||
|
||||
class Message(SettingType):
|
||||
"""
|
||||
Message type storing json-encoded message arguments.
|
||||
Messages without an embed are displayed differently from those with an embed.
|
||||
|
||||
Types:
|
||||
data: str
|
||||
A json dictionary with the fields `content` and `embed`.
|
||||
value: dict
|
||||
An argument dictionary suitable for `Message.send` or `Message.edit`.
|
||||
"""
|
||||
|
||||
_substitution_desc = {
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, id, value, **kwargs):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return json.dumps(value)
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
return json.loads(data)
|
||||
|
||||
@classmethod
|
||||
async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs):
|
||||
"""
|
||||
Return a setting instance initialised from a parsed user string.
|
||||
"""
|
||||
if ctx.msg.attachments:
|
||||
attachment = ctx.msg.attachments[0]
|
||||
if 'text' in attachment.content_type or 'json' in attachment.content_type:
|
||||
userstr = (await attachment.read()).decode()
|
||||
data = await cls._parse_userstr(ctx, id, userstr, as_json=True, **kwargs)
|
||||
else:
|
||||
raise UserInputError("Can't read the attached file!")
|
||||
else:
|
||||
data = await cls._parse_userstr(ctx, id, userstr, **kwargs)
|
||||
return cls(id, data, **kwargs)
|
||||
|
||||
@classmethod
|
||||
async def _parse_userstr(cls, ctx, id, userstr, as_json=False, **kwargs):
|
||||
"""
|
||||
Parse the provided string as either a content-only string, or json-format arguments.
|
||||
Provided string is not trusted, and is parsed in a safe manner.
|
||||
"""
|
||||
if userstr.lower() == 'none':
|
||||
return None
|
||||
|
||||
if as_json:
|
||||
try:
|
||||
args = json.loads(userstr)
|
||||
except json.JSONDecodeError:
|
||||
raise UserInputError(
|
||||
"Couldn't parse your message! "
|
||||
"You can test and fix it on the embed builder "
|
||||
"[here](https://glitchii.github.io/embedbuilder/?editor=json)."
|
||||
)
|
||||
if 'embed' in args and 'timestamp' in args['embed']:
|
||||
args['embed'].pop('timestamp')
|
||||
return json.dumps(args)
|
||||
else:
|
||||
return json.dumps({'content': userstr})
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Empty"
|
||||
value = cls._data_to_value(id, data, **kwargs)
|
||||
if 'embed' not in value and len(value['content']) < 100:
|
||||
return "`{}`".format(value['content'])
|
||||
else:
|
||||
return "Too long to display here!"
|
||||
|
||||
def substitution_keys(self, ctx, **kwargs):
|
||||
"""
|
||||
Instances should override this to provide their own substitution implementation.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def args(self, ctx, **kwargs):
|
||||
"""
|
||||
Applies the substitutions with the given context to generate the final message args.
|
||||
"""
|
||||
value = self.value
|
||||
substitutions = self.substitution_keys(ctx, **kwargs)
|
||||
args = {}
|
||||
if 'content' in value:
|
||||
args['content'] = multiple_replace(value['content'], substitutions)
|
||||
if 'embed' in value:
|
||||
args['embed'] = discord.Embed.from_dict(
|
||||
json.loads(multiple_replace(json.dumps(value['embed']), substitutions))
|
||||
)
|
||||
return args
|
||||
|
||||
async def widget(self, ctx, **kwargs):
|
||||
value = self.value
|
||||
args = self.args(ctx, **kwargs)
|
||||
|
||||
if not value:
|
||||
return await ctx.reply(embed=self.embed)
|
||||
|
||||
current_str = None
|
||||
preview = None
|
||||
file_content = None
|
||||
if 'embed' in value or len(value['content']) > 1024:
|
||||
current_str = "See attached file."
|
||||
file_content = json.dumps(value, indent=4)
|
||||
elif "`" in value['content']:
|
||||
current_str = "```{}```".format(value['content'])
|
||||
if len(args['content']) < 1000:
|
||||
preview = args['content']
|
||||
else:
|
||||
current_str = "`{}`".format(value['content'])
|
||||
if len(args['content']) < 1000:
|
||||
preview = args['content']
|
||||
|
||||
description = "{}\n\n**Current Value**: {}".format(
|
||||
self.long_desc.format(self=self, client=self.client),
|
||||
current_str
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Configuration options for `{}`".format(self.display_name),
|
||||
description=description
|
||||
)
|
||||
if preview:
|
||||
embed.add_field(name="Message Preview", value=preview, inline=False)
|
||||
embed.add_field(
|
||||
name="Setting Guide",
|
||||
value=(
|
||||
"• For plain text without an embed, use `{prefix}config {setting} <text>`.\n"
|
||||
"• To include an embed, build the message [here]({builder}) "
|
||||
"and upload the json code as a file with the `{prefix}config {setting}` command.\n"
|
||||
"• To reset the message to the default, use `{prefix}config {setting} None`."
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
setting=self.display_name,
|
||||
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
if self._substitution_desc:
|
||||
embed.add_field(
|
||||
name="Substitution Keys",
|
||||
value=(
|
||||
"*The following keys will be substituted for their current values.*\n{}"
|
||||
).format(
|
||||
prop_tabulate(*zip(*self._substitution_desc.items()), colon=False)
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(
|
||||
text="React with {} to preview the message.".format(preview_emoji)
|
||||
)
|
||||
if file_content:
|
||||
with StringIO() as message_file:
|
||||
message_file.write(file_content)
|
||||
message_file.seek(0)
|
||||
out_file = discord.File(message_file, filename="{}.json".format(self.display_name))
|
||||
out_msg = await ctx.reply(embed=embed, file=out_file)
|
||||
else:
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
# Add the preview reaction and send the preview when requested
|
||||
try:
|
||||
await out_msg.add_reaction(preview_emoji)
|
||||
except discord.HTTPException:
|
||||
return
|
||||
|
||||
try:
|
||||
await ctx.client.wait_for(
|
||||
'reaction_add',
|
||||
check=lambda r, u: r.message.id == out_msg.id and r.emoji == preview_emoji and u == ctx.author,
|
||||
timeout=180
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.remove_reaction(preview_emoji, ctx.client.user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
await ctx.offer_delete(
|
||||
await ctx.reply(**args, allowed_mentions=discord.AllowedMentions.none())
|
||||
)
|
||||
except discord.HTTPException as e:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
title="Preview failed! Error below",
|
||||
description="```{}```".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SettingList(SettingType):
|
||||
"""
|
||||
List of a particular type of setting.
|
||||
|
||||
@@ -17,7 +17,7 @@ tick = '✅'
|
||||
cross = '❌'
|
||||
|
||||
|
||||
def prop_tabulate(prop_list, value_list, indent=True):
|
||||
def prop_tabulate(prop_list, value_list, indent=True, colon=True):
|
||||
"""
|
||||
Turns a list of properties and corresponding list of values into
|
||||
a pretty string with one `prop: value` pair each line,
|
||||
@@ -39,7 +39,7 @@ def prop_tabulate(prop_list, value_list, indent=True):
|
||||
max_len = max(len(prop) for prop in prop_list)
|
||||
return "".join(["`{}{}{}`\t{}{}".format(" " * (max_len - len(prop)) if indent else "",
|
||||
prop,
|
||||
":" if len(prop) else " " * 2,
|
||||
(":" if len(prop) else " " * 2) if colon else '',
|
||||
value_list[i],
|
||||
'' if str(value_list[i]).endswith("```") else '\n')
|
||||
for i, prop in enumerate(prop_list)])
|
||||
@@ -540,3 +540,14 @@ def utc_now():
|
||||
Return the current timezone-aware utc timestamp.
|
||||
"""
|
||||
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def multiple_replace(string, rep_dict):
|
||||
if rep_dict:
|
||||
pattern = re.compile(
|
||||
"|".join([re.escape(k) for k in sorted(rep_dict, key=len, reverse=True)]),
|
||||
flags=re.DOTALL
|
||||
)
|
||||
return pattern.sub(lambda x: str(rep_dict[x.group(0)]), string)
|
||||
else:
|
||||
return string
|
||||
|
||||
Reference in New Issue
Block a user